2303 lines
85 KiB
Python
2303 lines
85 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import asdict, dataclass, field, replace
|
|
from datetime import UTC, date, datetime
|
|
from email.utils import parseaddr
|
|
import hashlib
|
|
import json
|
|
import re
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.dataflows import (
|
|
DataflowDatasetRequest,
|
|
dataflow_dataset_output,
|
|
)
|
|
from govoplan_core.core.contact_points import (
|
|
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
|
ContactPointCandidate,
|
|
ContactPointResolution,
|
|
ContactPointResolutionProvider,
|
|
ContactPointResolutionRequest,
|
|
ContactPointSourceRequest,
|
|
)
|
|
from govoplan_core.core.distribution_lists import (
|
|
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
|
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
|
DistributionChannelCandidate,
|
|
DistributionChannelPolicyProvider,
|
|
DistributionChannelPolicyRequest,
|
|
DistributionExpansionRequest,
|
|
DistributionExpansionResult,
|
|
DistributionExplanation,
|
|
DistributionListConflictError,
|
|
DistributionListEntryRef,
|
|
DistributionListNotFoundError,
|
|
DistributionListUnavailableError,
|
|
DistributionProviderEvidence,
|
|
DistributionRecipientRef,
|
|
DistributionSnapshotRef,
|
|
DistributionSourceReference,
|
|
RecipientChannelFactsProvider,
|
|
RecipientChannelFactsRequest,
|
|
)
|
|
from govoplan_core.core.identity import (
|
|
CAPABILITY_IDENTITY_DIRECTORY,
|
|
IdentityDirectory,
|
|
)
|
|
from govoplan_core.core.idm import (
|
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
|
CAPABILITY_IDM_RELATIONSHIPS,
|
|
IdmFunctionAssignmentDirectory,
|
|
IdmRelationshipDirectory,
|
|
)
|
|
from govoplan_core.core.organizations import (
|
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
|
OrganizationDirectory,
|
|
)
|
|
from govoplan_core.security.time import utc_now
|
|
from govoplan_dist_lists.backend.db.models import (
|
|
DistributionList,
|
|
DistributionListRevision,
|
|
DistributionListSnapshot,
|
|
)
|
|
from govoplan_dist_lists.backend.service import (
|
|
entry_ref,
|
|
get_distribution_list,
|
|
get_distribution_list_revision,
|
|
source_ref,
|
|
)
|
|
|
|
|
|
ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
|
ADDRESSES_LOOKUP = "addresses.lookup"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ExpansionContext:
|
|
session: Session
|
|
principal: ApiPrincipal
|
|
registry: object | None
|
|
request: DistributionExpansionRequest
|
|
effective_at: datetime
|
|
diagnostics: list[DistributionExplanation] = field(default_factory=list)
|
|
evidence: list[DistributionProviderEvidence] = field(default_factory=list)
|
|
visited_entries: int = 0
|
|
resolved_candidates: int = 0
|
|
candidate_truncated: bool = False
|
|
policy_unavailable_reported: bool = False
|
|
channel_facts_unavailable_reported: bool = False
|
|
|
|
|
|
def expand_distribution_list(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
registry: object | None,
|
|
request: DistributionExpansionRequest,
|
|
) -> DistributionExpansionResult:
|
|
distribution_list = get_distribution_list(session, principal, request.list_id)
|
|
revision = get_distribution_list_revision(
|
|
session,
|
|
distribution_list,
|
|
revision=request.revision,
|
|
)
|
|
if revision.definition_kind == "template":
|
|
raise DistributionListUnavailableError(
|
|
"Distribution-list templates cannot be expanded directly."
|
|
)
|
|
effective_at = _aware(request.effective_at or utc_now())
|
|
parameters = _resolve_parameters(revision.parameter_schema, request.parameters)
|
|
context = _ExpansionContext(
|
|
session=session,
|
|
principal=principal,
|
|
registry=registry,
|
|
request=replace(request, parameters=parameters, effective_at=effective_at),
|
|
effective_at=effective_at,
|
|
)
|
|
recipients, excluded = _expand_revision(
|
|
context,
|
|
distribution_list,
|
|
revision,
|
|
parameters=parameters,
|
|
stack=(),
|
|
)
|
|
recipients, duplicate_rows = _deduplicate_recipients(
|
|
recipients,
|
|
duplicate_rule=str(revision.constraints.get("duplicate_rule") or "first"),
|
|
)
|
|
excluded.extend(duplicate_rows)
|
|
recipients = [
|
|
_apply_channel_decisions(context, distribution_list, revision, item)
|
|
for item in recipients
|
|
]
|
|
channel_excluded = [item for item in recipients if item.status != "usable"]
|
|
recipients = [item for item in recipients if item.status == "usable"]
|
|
excluded.extend(channel_excluded)
|
|
|
|
result_truncated = len(recipients) > request.limits.max_results
|
|
truncated = context.candidate_truncated or result_truncated
|
|
if result_truncated:
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"expansion.result_limit",
|
|
f"Expansion was limited to {request.limits.max_results} recipients.",
|
|
severity="warning",
|
|
)
|
|
)
|
|
recipients = recipients[: request.limits.max_results]
|
|
stale = any(item.stale for item in context.evidence)
|
|
generated_at = utc_now()
|
|
expansion_hash = _expansion_hash(
|
|
distribution_list,
|
|
revision,
|
|
request=context.request,
|
|
recipients=recipients,
|
|
excluded=excluded,
|
|
evidence=context.evidence,
|
|
)
|
|
result = DistributionExpansionResult(
|
|
source=source_ref(distribution_list, revision),
|
|
request=context.request,
|
|
recipients=tuple(recipients),
|
|
excluded=tuple(excluded),
|
|
diagnostics=tuple(_unique_explanations(context.diagnostics)),
|
|
provider_evidence=tuple(_unique_evidence(context.evidence)),
|
|
expansion_hash=expansion_hash,
|
|
generated_at=generated_at,
|
|
stale=stale,
|
|
truncated=truncated,
|
|
)
|
|
if request.freeze:
|
|
snapshot = _freeze_expansion(
|
|
session,
|
|
principal,
|
|
result,
|
|
revision=revision,
|
|
)
|
|
result = replace(result, snapshot_id=snapshot.id)
|
|
return result
|
|
|
|
|
|
def get_snapshot_ref(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
snapshot_id: str,
|
|
) -> DistributionSnapshotRef | None:
|
|
row = session.scalar(
|
|
select(DistributionListSnapshot).where(
|
|
DistributionListSnapshot.id == snapshot_id,
|
|
DistributionListSnapshot.tenant_id == principal.tenant_id,
|
|
)
|
|
)
|
|
if row is None:
|
|
return None
|
|
return snapshot_ref(row)
|
|
|
|
|
|
def snapshot_ref(row: DistributionListSnapshot) -> DistributionSnapshotRef:
|
|
return DistributionSnapshotRef(
|
|
id=row.id,
|
|
tenant_id=row.tenant_id,
|
|
list_id=row.distribution_list_id,
|
|
revision_id=row.revision_id,
|
|
revision=row.revision_number,
|
|
expansion_hash=row.expansion_hash,
|
|
generated_at=row.created_at,
|
|
effective_at=row.effective_at,
|
|
recipient_count=row.recipient_count,
|
|
excluded_count=row.excluded_count,
|
|
stale=row.stale,
|
|
truncated=row.truncated,
|
|
request=dict(row.request_),
|
|
recipients=tuple(_recipient_from_json(item) for item in row.recipients),
|
|
excluded=tuple(_recipient_from_json(item) for item in row.excluded),
|
|
diagnostics=tuple(_explanation_from_json(item) for item in row.diagnostics),
|
|
provider_evidence=tuple(
|
|
_evidence_from_json(item) for item in row.provider_evidence
|
|
),
|
|
provenance=dict(row.provenance),
|
|
)
|
|
|
|
|
|
def _expand_revision(
|
|
context: _ExpansionContext,
|
|
distribution_list: DistributionList,
|
|
revision: DistributionListRevision,
|
|
*,
|
|
parameters: Mapping[str, object],
|
|
stack: tuple[str, ...],
|
|
) -> tuple[list[DistributionRecipientRef], list[DistributionRecipientRef]]:
|
|
if distribution_list.id in stack:
|
|
cycle = " -> ".join((*stack, distribution_list.id))
|
|
explanation = _explanation(
|
|
"expansion.nested_cycle",
|
|
f"Nested distribution-list cycle detected: {cycle}.",
|
|
severity="error",
|
|
provider="dist_lists",
|
|
)
|
|
context.diagnostics.append(explanation)
|
|
return [], [_placeholder_recipient(distribution_list.id, explanation)]
|
|
if len(stack) >= context.request.limits.max_depth:
|
|
explanation = _explanation(
|
|
"expansion.depth_limit",
|
|
f"Nested lists are limited to {context.request.limits.max_depth} levels.",
|
|
severity="error",
|
|
provider="dist_lists",
|
|
)
|
|
context.diagnostics.append(explanation)
|
|
return [], [_placeholder_recipient(distribution_list.id, explanation)]
|
|
|
|
included: list[DistributionRecipientRef] = []
|
|
excluded: list[DistributionRecipientRef] = []
|
|
exclusion_keys: set[str] = set()
|
|
overrides: list[DistributionRecipientRef] = []
|
|
for model in revision.entries:
|
|
context.visited_entries += 1
|
|
if context.visited_entries > context.request.limits.max_entries:
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"expansion.entry_limit",
|
|
f"Expansion exceeded the {context.request.limits.max_entries}-entry limit.",
|
|
severity="error",
|
|
)
|
|
)
|
|
break
|
|
entry = _substitute_entry(entry_ref(model), parameters)
|
|
if not _entry_is_effective(entry, context.effective_at):
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"entry.not_effective",
|
|
f"{entry.label or entry.source.label or entry.id} is outside its effective period.",
|
|
severity="info",
|
|
provider=entry.source.provider,
|
|
source=entry.source,
|
|
)
|
|
)
|
|
continue
|
|
rows = _expand_entry(
|
|
context,
|
|
entry,
|
|
parameters=parameters,
|
|
stack=(*stack, distribution_list.id),
|
|
)
|
|
if entry.mode == "exclude":
|
|
exclusion_keys.update(_recipient_match_keys(row) for row in rows)
|
|
excluded.extend(
|
|
replace(
|
|
row,
|
|
status="suppressed",
|
|
explanations=(
|
|
*row.explanations,
|
|
_explanation(
|
|
"entry.explicit_exclusion",
|
|
"Recipient was removed by an explicit exclusion entry.",
|
|
provider="dist_lists",
|
|
source=entry.source,
|
|
),
|
|
),
|
|
)
|
|
for row in rows
|
|
)
|
|
elif entry.mode == "override":
|
|
overrides.extend(rows)
|
|
else:
|
|
included.extend(rows)
|
|
|
|
kept: list[DistributionRecipientRef] = []
|
|
for row in included:
|
|
if _recipient_match_keys(row) in exclusion_keys:
|
|
excluded.append(
|
|
replace(
|
|
row,
|
|
status="suppressed",
|
|
explanations=(
|
|
*row.explanations,
|
|
_explanation(
|
|
"entry.excluded",
|
|
"Recipient matched an exclusion entry.",
|
|
provider="dist_lists",
|
|
),
|
|
),
|
|
)
|
|
)
|
|
else:
|
|
kept.append(row)
|
|
for row in overrides:
|
|
override_reason = _text(row.provenance.get("override_reason"))
|
|
if not override_reason:
|
|
excluded.append(
|
|
replace(
|
|
row,
|
|
status="policy_blocked",
|
|
explanations=(
|
|
*row.explanations,
|
|
_explanation(
|
|
"override.reason_required",
|
|
"Manual overrides require a recorded reason.",
|
|
severity="error",
|
|
provider="dist_lists",
|
|
),
|
|
),
|
|
)
|
|
)
|
|
continue
|
|
key = _recipient_match_keys(row)
|
|
kept = [item for item in kept if _recipient_match_keys(item) != key]
|
|
kept.append(row)
|
|
return kept, excluded
|
|
|
|
|
|
def _expand_entry(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
*,
|
|
parameters: Mapping[str, object],
|
|
stack: tuple[str, ...],
|
|
) -> list[DistributionRecipientRef]:
|
|
candidate_limit = (
|
|
context.request.limits.max_results
|
|
+ context.request.limits.max_provider_results
|
|
)
|
|
if context.resolved_candidates >= candidate_limit:
|
|
if not context.candidate_truncated:
|
|
context.candidate_truncated = True
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"expansion.candidate_limit",
|
|
f"Expansion candidate processing was limited to {candidate_limit} rows.",
|
|
severity="warning",
|
|
provider=entry.source.provider,
|
|
source=entry.source,
|
|
)
|
|
)
|
|
return []
|
|
if entry.kind == "raw_email":
|
|
return _bounded_candidates(context, entry, [_raw_email_recipient(entry)])
|
|
if entry.kind == "raw_postal_address":
|
|
return _bounded_candidates(context, entry, [_raw_postal_recipient(entry)])
|
|
if entry.kind in {"internal_mail", "portal"}:
|
|
return _bounded_candidates(context, entry, [_raw_digital_recipient(entry)])
|
|
if entry.kind == "distribution_list":
|
|
return _nested_list_recipients(context, entry, parameters=parameters, stack=stack)
|
|
if entry.kind in {"address_list", "address_contact", "address_email"}:
|
|
return _bounded_candidates(context, entry, _address_recipients(context, entry))
|
|
if entry.kind == "idm_identity":
|
|
return _bounded_candidates(context, entry, _identity_recipients(context, entry))
|
|
if entry.kind == "idm_group":
|
|
return _bounded_candidates(context, entry, _idm_group_recipients(context, entry))
|
|
if entry.kind in {"organization_unit", "function", "effective_function_incumbent"}:
|
|
return _bounded_candidates(
|
|
context,
|
|
entry,
|
|
_organization_recipients(context, entry),
|
|
)
|
|
if entry.kind == "dataflow_result":
|
|
return _bounded_candidates(
|
|
context,
|
|
entry,
|
|
_dataflow_recipients(context, entry, parameters=parameters),
|
|
)
|
|
return _bounded_candidates(
|
|
context,
|
|
entry,
|
|
[_provider_unavailable(entry, f"Unsupported entry kind: {entry.kind}.")],
|
|
)
|
|
|
|
|
|
def _bounded_candidates(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
rows: Sequence[DistributionRecipientRef],
|
|
) -> list[DistributionRecipientRef]:
|
|
candidate_limit = (
|
|
context.request.limits.max_results
|
|
+ context.request.limits.max_provider_results
|
|
)
|
|
remaining = max(0, candidate_limit - context.resolved_candidates)
|
|
accepted = list(rows[:remaining])
|
|
context.resolved_candidates += len(accepted)
|
|
if len(rows) > len(accepted) and not context.candidate_truncated:
|
|
context.candidate_truncated = True
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"expansion.candidate_limit",
|
|
f"Expansion candidate processing was limited to {candidate_limit} rows.",
|
|
severity="warning",
|
|
provider=entry.source.provider,
|
|
source=entry.source,
|
|
)
|
|
)
|
|
return accepted
|
|
|
|
|
|
def _raw_email_recipient(entry: DistributionListEntryRef) -> DistributionRecipientRef:
|
|
display, address = parseaddr(entry.source.resource_id)
|
|
valid = bool(address and "@" in address and len(address) <= 320)
|
|
source = entry.source
|
|
channel = DistributionChannelCandidate(
|
|
channel="email",
|
|
target=address or entry.source.resource_id,
|
|
target_key=f"email:{(address or entry.source.resource_id).casefold()}",
|
|
status="usable" if valid else "invalid",
|
|
reason_code=None if valid else "email.invalid",
|
|
explanation=None if valid else "The email address is invalid.",
|
|
source=source,
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=channel.target_key,
|
|
display_name=entry.label or display or address or entry.source.resource_id,
|
|
status=channel.status,
|
|
channels=(channel,),
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(
|
|
()
|
|
if valid
|
|
else (
|
|
_explanation(
|
|
"email.invalid",
|
|
"The email address is invalid.",
|
|
severity="error",
|
|
provider=source.provider,
|
|
source=source,
|
|
),
|
|
)
|
|
),
|
|
provenance=_entry_provenance(entry),
|
|
)
|
|
|
|
|
|
def _raw_postal_recipient(entry: DistributionListEntryRef) -> DistributionRecipientRef:
|
|
target = _text(entry.configuration.get("formatted_address")) or entry.source.resource_id
|
|
valid = bool(target.strip())
|
|
channel = DistributionChannelCandidate(
|
|
channel="postal",
|
|
target=target,
|
|
target_key=f"postal:{_normalized_target(target)}",
|
|
status="usable" if valid else "invalid",
|
|
reason_code=None if valid else "postal.invalid",
|
|
explanation=None if valid else "The postal address is empty.",
|
|
source=entry.source,
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=channel.target_key,
|
|
display_name=entry.label or entry.source.label or target,
|
|
status=channel.status,
|
|
channels=(channel,),
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(),
|
|
attributes=dict(entry.configuration),
|
|
provenance=_entry_provenance(entry),
|
|
)
|
|
|
|
|
|
def _raw_digital_recipient(entry: DistributionListEntryRef) -> DistributionRecipientRef:
|
|
channel_name = "portal" if entry.kind == "portal" else "internal_mail"
|
|
target = entry.source.resource_id.strip()
|
|
channel = DistributionChannelCandidate(
|
|
channel=channel_name,
|
|
target=target,
|
|
target_key=f"{channel_name}:{target.casefold()}",
|
|
status="usable" if target else "invalid",
|
|
source=entry.source,
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=channel.target_key,
|
|
display_name=entry.label or entry.source.label or target,
|
|
status=channel.status,
|
|
channels=(channel,),
|
|
account_id=target if channel_name == "internal_mail" else None,
|
|
source_entry_ids=(entry.id,),
|
|
provenance=_entry_provenance(entry),
|
|
)
|
|
|
|
|
|
def _nested_list_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
*,
|
|
parameters: Mapping[str, object],
|
|
stack: tuple[str, ...],
|
|
) -> list[DistributionRecipientRef]:
|
|
try:
|
|
nested = get_distribution_list(
|
|
context.session,
|
|
context.principal,
|
|
entry.source.resource_id,
|
|
)
|
|
nested_revision = get_distribution_list_revision(
|
|
context.session,
|
|
nested,
|
|
revision=int(entry.source.revision) if entry.source.revision else None,
|
|
)
|
|
mapped_parameters = entry.configuration.get("parameters")
|
|
nested_parameters = _resolve_parameters(
|
|
nested_revision.parameter_schema,
|
|
mapped_parameters if isinstance(mapped_parameters, Mapping) else parameters,
|
|
)
|
|
included, excluded = _expand_revision(
|
|
context,
|
|
nested,
|
|
nested_revision,
|
|
parameters=nested_parameters,
|
|
stack=stack,
|
|
)
|
|
context.diagnostics.extend(
|
|
explanation
|
|
for row in excluded
|
|
for explanation in row.explanations
|
|
if explanation.severity == "error"
|
|
)
|
|
return [
|
|
replace(
|
|
row,
|
|
source_entry_ids=tuple(dict.fromkeys((*row.source_entry_ids, entry.id))),
|
|
provenance={
|
|
**dict(row.provenance),
|
|
"nested_distribution_list_id": nested.id,
|
|
"nested_revision": nested_revision.revision,
|
|
},
|
|
)
|
|
for row in included
|
|
]
|
|
except (DistributionListConflictError, ValueError) as exc:
|
|
return [_unresolved(entry, "nested_list.unavailable", str(exc))]
|
|
except DistributionListNotFoundError as exc:
|
|
return [_unresolved(entry, "nested_list.not_found", str(exc))]
|
|
|
|
|
|
def _address_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
) -> list[DistributionRecipientRef]:
|
|
provider = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
|
ContactPointResolutionProvider,
|
|
)
|
|
if provider is not None:
|
|
return _contact_point_recipients(context, entry, provider)
|
|
return _legacy_address_recipients(context, entry)
|
|
|
|
|
|
def _contact_point_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
provider: ContactPointResolutionProvider,
|
|
) -> list[DistributionRecipientRef]:
|
|
requested_channels, constrained = _requested_channel_filter(context, entry)
|
|
if constrained and not requested_channels:
|
|
return [
|
|
_unresolved(
|
|
entry,
|
|
"channel.no_common_selection",
|
|
"The entry and expansion request do not allow a common delivery channel.",
|
|
status="suppressed",
|
|
)
|
|
]
|
|
if entry.kind == "address_list":
|
|
return _contact_point_source_recipients(
|
|
context,
|
|
entry,
|
|
provider,
|
|
requested_channels=requested_channels,
|
|
)
|
|
try:
|
|
resolution = provider.resolve_contact_points(
|
|
context.session,
|
|
context.principal,
|
|
request=ContactPointResolutionRequest(
|
|
tenant_id=context.principal.tenant_id,
|
|
subject=entry.source,
|
|
effective_at=context.effective_at,
|
|
purpose=entry.purpose or context.request.purpose,
|
|
requested_channels=requested_channels,
|
|
address_purpose=_text(entry.configuration.get("address_purpose")),
|
|
fallback_rule=_contact_point_fallback_rule(entry),
|
|
locale=_text(entry.configuration.get("locale")),
|
|
postal_format=_postal_format(entry),
|
|
context={
|
|
"distribution_list_id": context.request.list_id,
|
|
"distribution_list_entry_id": entry.id,
|
|
},
|
|
),
|
|
)
|
|
except (LookupError, PermissionError, ValueError) as exc:
|
|
return [_unresolved(entry, "addresses.contact_point_failed", str(exc))]
|
|
contact_revision = _contact_record_revision(resolution)
|
|
stale = _is_stale(
|
|
entry.source,
|
|
actual_revision=contact_revision,
|
|
actual_fingerprint=resolution.source_fingerprint,
|
|
)
|
|
context.evidence.append(
|
|
_contact_point_evidence(
|
|
entry,
|
|
actual_revision=contact_revision or resolution.source_revision,
|
|
actual_fingerprint=resolution.source_fingerprint,
|
|
stale=stale,
|
|
details={
|
|
"contract_version": resolution.contract_version,
|
|
"contact_id": resolution.contact_id,
|
|
"contact_point_revision": resolution.source_revision,
|
|
"contact_point_fingerprint": resolution.source_fingerprint,
|
|
"provenance": dict(resolution.provenance),
|
|
},
|
|
)
|
|
)
|
|
return [
|
|
_contact_point_recipient(
|
|
entry,
|
|
resolution,
|
|
stale=stale,
|
|
expected_email=(
|
|
_text(entry.configuration.get("email"))
|
|
or _text(entry.source.metadata.get("email"))
|
|
if entry.kind == "address_email"
|
|
else None
|
|
),
|
|
)
|
|
]
|
|
|
|
|
|
def _contact_point_source_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
provider: ContactPointResolutionProvider,
|
|
*,
|
|
requested_channels: tuple[str, ...],
|
|
) -> list[DistributionRecipientRef]:
|
|
source_id = _contact_point_source_id(entry)
|
|
source_request = ContactPointSourceRequest(
|
|
tenant_id=context.principal.tenant_id,
|
|
source_id=source_id,
|
|
effective_at=context.effective_at,
|
|
purpose=entry.purpose or context.request.purpose,
|
|
requested_channels=requested_channels, # type: ignore[arg-type]
|
|
address_purpose=_text(entry.configuration.get("address_purpose")),
|
|
fallback_rule=_contact_point_fallback_rule(entry),
|
|
locale=_text(entry.configuration.get("locale")),
|
|
postal_format=_postal_format(entry),
|
|
max_items=20_000,
|
|
context={
|
|
"distribution_list_id": context.request.list_id,
|
|
"distribution_list_entry_id": entry.id,
|
|
},
|
|
)
|
|
resolutions: list[ContactPointResolution] = []
|
|
preview = None
|
|
source_revision: str | None = None
|
|
source_fingerprint: str | None = None
|
|
offset = 0
|
|
provider_limit = context.request.limits.max_provider_results
|
|
try:
|
|
while len(resolutions) < provider_limit:
|
|
page_limit = min(500, provider_limit - len(resolutions))
|
|
preview = provider.preview_source(
|
|
context.session,
|
|
context.principal,
|
|
request=source_request,
|
|
offset=offset,
|
|
limit=page_limit,
|
|
)
|
|
if source_revision is None:
|
|
source_revision = preview.source_revision
|
|
source_fingerprint = preview.source_fingerprint
|
|
elif (
|
|
preview.source_revision != source_revision
|
|
or preview.source_fingerprint != source_fingerprint
|
|
):
|
|
return [
|
|
_unresolved(
|
|
entry,
|
|
"addresses.source_changed",
|
|
"The Addresses source changed while it was being expanded; retry the expansion.",
|
|
status="stale",
|
|
)
|
|
]
|
|
resolutions.extend(preview.resolutions)
|
|
offset += len(preview.resolutions)
|
|
if not preview.has_more or not preview.resolutions:
|
|
break
|
|
except (LookupError, PermissionError, ValueError) as exc:
|
|
return [_unresolved(entry, "addresses.source_failed", str(exc))]
|
|
if preview is None:
|
|
return []
|
|
stale = _is_stale(
|
|
entry.source,
|
|
actual_revision=source_revision,
|
|
actual_fingerprint=source_fingerprint,
|
|
)
|
|
context.evidence.append(
|
|
_contact_point_evidence(
|
|
entry,
|
|
actual_revision=source_revision,
|
|
actual_fingerprint=source_fingerprint,
|
|
stale=stale,
|
|
generated_at=preview.generated_at,
|
|
details={
|
|
"contract_version": preview.contract_version,
|
|
"source_id": source_id,
|
|
"total_count": preview.total_count,
|
|
"provenance": dict(preview.provenance),
|
|
},
|
|
)
|
|
)
|
|
if preview.total_count > len(resolutions):
|
|
context.candidate_truncated = True
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"provider.result_limit",
|
|
"Addresses results were truncated by the provider limit.",
|
|
provider="addresses",
|
|
source=entry.source,
|
|
)
|
|
)
|
|
return [
|
|
_contact_point_recipient(entry, resolution, stale=stale)
|
|
for resolution in resolutions
|
|
]
|
|
|
|
|
|
def _legacy_address_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
) -> list[DistributionRecipientRef]:
|
|
if entry.kind == "address_list":
|
|
provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE)
|
|
if provider is None or not hasattr(provider, "snapshot"):
|
|
return [_provider_unavailable(entry, "Addresses recipient sources are unavailable.")]
|
|
try:
|
|
snapshot = provider.snapshot(
|
|
context.session,
|
|
context.principal,
|
|
source_id=entry.source.resource_id,
|
|
)
|
|
except (LookupError, PermissionError, ValueError) as exc:
|
|
return [_unresolved(entry, "addresses.source_failed", str(exc))]
|
|
actual_revision = _text(getattr(snapshot, "source_revision", None))
|
|
stale = _is_stale(entry.source, actual_revision=actual_revision)
|
|
context.evidence.append(
|
|
DistributionProviderEvidence(
|
|
provider="addresses",
|
|
source=entry.source,
|
|
actual_revision=actual_revision,
|
|
stale=stale,
|
|
generated_at=_parse_datetime(getattr(snapshot, "generated_at", None)),
|
|
details=dict(getattr(snapshot, "provenance", {}) or {}),
|
|
)
|
|
)
|
|
rows = list(getattr(snapshot, "recipients", ()))[: context.request.limits.max_provider_results]
|
|
if len(getattr(snapshot, "recipients", ())) > len(rows):
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"provider.result_limit",
|
|
"Addresses results were truncated by the provider limit.",
|
|
provider="addresses",
|
|
source=entry.source,
|
|
)
|
|
)
|
|
return [_address_snapshot_recipient(entry, row, stale=stale) for row in rows]
|
|
|
|
provider = _capability(context.registry, ADDRESSES_LOOKUP)
|
|
if provider is None or not hasattr(provider, "lookup"):
|
|
return [_provider_unavailable(entry, "Addresses lookup is unavailable.")]
|
|
try:
|
|
candidates = provider.lookup(
|
|
context.session,
|
|
context.principal,
|
|
query=entry.source.resource_id,
|
|
limit=100,
|
|
)
|
|
except (LookupError, PermissionError, ValueError) as exc:
|
|
return [_unresolved(entry, "addresses.lookup_failed", str(exc))]
|
|
exact = [
|
|
item
|
|
for item in candidates
|
|
if str(getattr(item, "contact_id", "")) == entry.source.resource_id
|
|
]
|
|
if entry.kind == "address_email":
|
|
expected = _text(
|
|
entry.configuration.get("email")
|
|
or entry.source.metadata.get("email")
|
|
)
|
|
exact = [item for item in exact if expected is None or getattr(item, "email", None) == expected]
|
|
if not exact:
|
|
return [_unresolved(entry, "addresses.contact_not_found", "Address contact could not be resolved.")]
|
|
return [_address_lookup_recipient(entry, item) for item in exact]
|
|
|
|
|
|
def _contact_point_recipient(
|
|
entry: DistributionListEntryRef,
|
|
resolution: ContactPointResolution,
|
|
*,
|
|
stale: bool,
|
|
expected_email: str | None = None,
|
|
) -> DistributionRecipientRef:
|
|
accepted = list(resolution.candidates)
|
|
rejected = list(resolution.excluded)
|
|
if expected_email is not None:
|
|
matching: list[ContactPointCandidate] = []
|
|
for candidate in accepted:
|
|
if candidate.channel == "email" and candidate.target.casefold() == expected_email.casefold():
|
|
matching.append(candidate)
|
|
else:
|
|
rejected.append(
|
|
replace(
|
|
candidate,
|
|
status="suppressed",
|
|
reason_code="addresses.email.not_selected",
|
|
explanation="This contact point is not the email address selected by the list entry.",
|
|
)
|
|
)
|
|
accepted = matching
|
|
channels = tuple(
|
|
_distribution_channel_candidate(item, entry=entry, stale=stale)
|
|
for item in (*accepted, *rejected)
|
|
)
|
|
selected_channels = channels[: len(accepted)]
|
|
if any(item.status == "usable" for item in selected_channels):
|
|
status = "usable"
|
|
elif any(item.status == "stale" for item in selected_channels):
|
|
status = "stale"
|
|
else:
|
|
status = _recipient_outcome(channels, resolution.status)
|
|
explanations = [*resolution.explanations]
|
|
explanations.extend(
|
|
DistributionExplanation(
|
|
code=item.reason_code,
|
|
message=item.explanation,
|
|
severity="warning",
|
|
provider="addresses",
|
|
source=item.source or entry.source,
|
|
provenance=dict(item.provenance),
|
|
)
|
|
for item in rejected
|
|
if item.reason_code and item.explanation
|
|
)
|
|
contact_id = resolution.contact_id
|
|
return DistributionRecipientRef(
|
|
recipient_key=f"contact:{contact_id}" if contact_id else f"unresolved:{entry.id}",
|
|
display_name=(
|
|
resolution.display_name
|
|
or entry.label
|
|
or entry.source.label
|
|
or contact_id
|
|
or entry.source.resource_id
|
|
),
|
|
status=status, # type: ignore[arg-type]
|
|
channels=channels,
|
|
contact_id=contact_id,
|
|
source_entry_ids=(entry.id,),
|
|
explanations=tuple(_unique_explanations(explanations)),
|
|
attributes={
|
|
"contact_points": [
|
|
{
|
|
"channel": item.channel,
|
|
"contact_point_id": item.contact_point_id,
|
|
"address_purpose": item.address_purpose,
|
|
"value": dict(item.value),
|
|
}
|
|
for item in (*accepted, *rejected)
|
|
],
|
|
},
|
|
provenance={
|
|
**_entry_provenance(entry),
|
|
"contact_point_resolution": {
|
|
"contract_version": resolution.contract_version,
|
|
"source_revision": resolution.source_revision,
|
|
"source_fingerprint": resolution.source_fingerprint,
|
|
"provenance": dict(resolution.provenance),
|
|
},
|
|
"channel_facts_resolved": True,
|
|
},
|
|
)
|
|
|
|
|
|
def _distribution_channel_candidate(
|
|
candidate: ContactPointCandidate,
|
|
*,
|
|
entry: DistributionListEntryRef,
|
|
stale: bool,
|
|
) -> DistributionChannelCandidate:
|
|
status = candidate.status
|
|
reason_code = candidate.reason_code
|
|
explanation = candidate.explanation
|
|
if stale and status == "usable":
|
|
status = "stale"
|
|
reason_code = "source.stale"
|
|
explanation = "The source revision changed."
|
|
return DistributionChannelCandidate(
|
|
channel=candidate.channel,
|
|
target=candidate.target,
|
|
target_key=candidate.target_key,
|
|
status=status,
|
|
contact_point_id=candidate.contact_point_id,
|
|
locale=candidate.locale,
|
|
preferred=candidate.preferred,
|
|
reason_code=reason_code,
|
|
explanation=explanation,
|
|
source=candidate.source or entry.source,
|
|
decision_provenance={
|
|
**dict(candidate.provenance),
|
|
"address_purpose": candidate.address_purpose,
|
|
"preference_rank": candidate.preference_rank,
|
|
"source_revision": candidate.source_revision,
|
|
"preference_revision": candidate.preference_revision,
|
|
"consent_revision": candidate.consent_revision,
|
|
"value": dict(candidate.value),
|
|
},
|
|
)
|
|
|
|
|
|
def _contact_point_evidence(
|
|
entry: DistributionListEntryRef,
|
|
*,
|
|
actual_revision: str | None,
|
|
actual_fingerprint: str | None,
|
|
stale: bool,
|
|
details: Mapping[str, object],
|
|
generated_at: datetime | None = None,
|
|
) -> DistributionProviderEvidence:
|
|
return DistributionProviderEvidence(
|
|
provider="addresses",
|
|
source=entry.source,
|
|
actual_revision=actual_revision,
|
|
actual_fingerprint=actual_fingerprint,
|
|
stale=stale,
|
|
generated_at=generated_at,
|
|
details=details,
|
|
)
|
|
|
|
|
|
def _contact_record_revision(resolution: ContactPointResolution) -> str | None:
|
|
revisions = {
|
|
str(revision).strip()
|
|
for candidate in (*resolution.candidates, *resolution.excluded)
|
|
if (revision := candidate.provenance.get("source_revision")) is not None
|
|
and str(revision).strip()
|
|
}
|
|
return next(iter(revisions)) if len(revisions) == 1 else None
|
|
|
|
|
|
def _contact_point_source_id(entry: DistributionListEntryRef) -> str:
|
|
resource_id = entry.source.resource_id
|
|
if resource_id.startswith("addresses:"):
|
|
return resource_id
|
|
resource_type = entry.source.resource_type
|
|
if resource_type in {"address_book", "book"}:
|
|
return f"addresses:address_book:{resource_id}"
|
|
return f"addresses:address_list:{resource_id}"
|
|
|
|
|
|
def _contact_point_fallback_rule(entry: DistributionListEntryRef):
|
|
value = _text(entry.configuration.get("fallback_rule")) or "primary"
|
|
return value if value in {"none", "primary", "any"} else "primary"
|
|
|
|
|
|
def _postal_format(entry: DistributionListEntryRef):
|
|
value = _text(entry.configuration.get("postal_format")) or "domestic"
|
|
return value if value in {"domestic", "international"} else "domestic"
|
|
|
|
|
|
def _requested_channel_filter(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
) -> tuple[tuple[str, ...], bool]:
|
|
entry_channels = set(entry.requested_channels)
|
|
if entry.kind == "address_email":
|
|
entry_channels = {"email"}
|
|
caller_channels = set(context.request.requested_channels)
|
|
if entry_channels and caller_channels:
|
|
selected = entry_channels.intersection(caller_channels)
|
|
else:
|
|
selected = entry_channels or caller_channels
|
|
return tuple(sorted(selected)), bool(entry_channels or caller_channels)
|
|
|
|
|
|
def _address_snapshot_recipient(
|
|
entry: DistributionListEntryRef,
|
|
item: object,
|
|
*,
|
|
stale: bool,
|
|
) -> DistributionRecipientRef:
|
|
contact_id = str(getattr(item, "contact_id", "")) or None
|
|
email = str(getattr(item, "email", ""))
|
|
source = DistributionSourceReference(
|
|
provider="addresses",
|
|
resource_type="contact",
|
|
resource_id=contact_id or email,
|
|
revision=entry.source.revision,
|
|
fingerprint=entry.source.fingerprint,
|
|
label=str(getattr(item, "display_name", "")) or None,
|
|
metadata=dict(getattr(item, "provenance", {}) or {}),
|
|
)
|
|
channel = DistributionChannelCandidate(
|
|
channel="email",
|
|
target=email,
|
|
target_key=f"email:{email.casefold()}",
|
|
status="stale" if stale else "usable",
|
|
reason_code="source.stale" if stale else None,
|
|
explanation="The source revision changed." if stale else None,
|
|
source=source,
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=f"contact:{contact_id}" if contact_id else channel.target_key,
|
|
display_name=str(getattr(item, "display_name", "")) or email,
|
|
status=channel.status,
|
|
channels=(channel,),
|
|
contact_id=contact_id,
|
|
source_entry_ids=(entry.id,),
|
|
attributes=dict(getattr(item, "fields", {}) or {}),
|
|
provenance={
|
|
**_entry_provenance(entry),
|
|
**dict(getattr(item, "provenance", {}) or {}),
|
|
},
|
|
)
|
|
|
|
|
|
def _address_lookup_recipient(
|
|
entry: DistributionListEntryRef,
|
|
item: object,
|
|
) -> DistributionRecipientRef:
|
|
contact_id = str(getattr(item, "contact_id"))
|
|
email = _text(getattr(item, "email", None))
|
|
channels = (
|
|
(
|
|
DistributionChannelCandidate(
|
|
channel="email",
|
|
target=email,
|
|
target_key=f"email:{email.casefold()}",
|
|
contact_point_id=None,
|
|
source=entry.source,
|
|
),
|
|
)
|
|
if email
|
|
else ()
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=f"contact:{contact_id}",
|
|
display_name=str(getattr(item, "display_name", "")) or contact_id,
|
|
status="usable" if channels else "unresolved",
|
|
channels=channels,
|
|
contact_id=contact_id,
|
|
source_entry_ids=(entry.id,),
|
|
attributes={
|
|
"organization": getattr(item, "organization", None),
|
|
"role_title": getattr(item, "role_title", None),
|
|
"tags": tuple(getattr(item, "tags", ()) or ()),
|
|
},
|
|
provenance={
|
|
**_entry_provenance(entry),
|
|
**dict(getattr(item, "provenance", {}) or {}),
|
|
},
|
|
)
|
|
|
|
|
|
def _identity_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
) -> list[DistributionRecipientRef]:
|
|
provider = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_IDENTITY_DIRECTORY,
|
|
IdentityDirectory,
|
|
)
|
|
if provider is None:
|
|
return [_provider_unavailable(entry, "Identity directory is unavailable.")]
|
|
identity = provider.get_identity(entry.source.resource_id)
|
|
if identity is None or identity.status != "active":
|
|
return [_unresolved(entry, "identity.not_active", "Identity is missing or inactive.")]
|
|
account_id = identity.primary_account_id or next(iter(identity.account_ids), None)
|
|
if account_id is None:
|
|
return [_unresolved(entry, "identity.no_account", "Identity has no linked account.")]
|
|
channel = DistributionChannelCandidate(
|
|
channel="internal_mail",
|
|
target=account_id,
|
|
target_key=f"internal_mail:{account_id}",
|
|
source=entry.source,
|
|
)
|
|
return [
|
|
DistributionRecipientRef(
|
|
recipient_key=f"identity:{identity.id}",
|
|
display_name=identity.display_name or identity.id,
|
|
status="usable",
|
|
channels=(channel,),
|
|
identity_id=identity.id,
|
|
account_id=account_id,
|
|
source_entry_ids=(entry.id,),
|
|
provenance=_entry_provenance(entry),
|
|
)
|
|
]
|
|
|
|
|
|
def _idm_group_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
) -> list[DistributionRecipientRef]:
|
|
relationships = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_IDM_RELATIONSHIPS,
|
|
IdmRelationshipDirectory,
|
|
)
|
|
identities = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_IDENTITY_DIRECTORY,
|
|
IdentityDirectory,
|
|
)
|
|
if relationships is None or identities is None:
|
|
return [
|
|
_provider_unavailable(
|
|
entry,
|
|
"IDM relationship and Identity directory capabilities are required.",
|
|
)
|
|
]
|
|
configured_kinds = entry.configuration.get("relationship_kinds", ("member",))
|
|
relationship_kinds = (
|
|
tuple(str(item) for item in configured_kinds if str(item).strip())
|
|
if isinstance(configured_kinds, Sequence)
|
|
and not isinstance(configured_kinds, (str, bytes))
|
|
else (str(configured_kinds),)
|
|
)
|
|
try:
|
|
resolved = relationships.resolve_typed_group_memberships(
|
|
(entry.source.resource_id,),
|
|
tenant_id=context.principal.tenant_id,
|
|
effective_at=context.effective_at,
|
|
relationship_kinds=relationship_kinds or ("member",),
|
|
).get(entry.source.resource_id)
|
|
except (LookupError, PermissionError, ValueError) as exc:
|
|
return [_unresolved(entry, "idm.group_resolution_failed", str(exc))]
|
|
if resolved is None:
|
|
return [_unresolved(entry, "idm.group_not_found", "Typed IDM group not found.")]
|
|
|
|
actual_revision = resolved.group.source_revision or str(resolved.group.revision)
|
|
stale = bool(
|
|
entry.source.revision
|
|
and entry.source.revision != actual_revision
|
|
)
|
|
context.evidence.append(
|
|
DistributionProviderEvidence(
|
|
provider="idm",
|
|
source=entry.source,
|
|
actual_revision=actual_revision,
|
|
stale=stale,
|
|
generated_at=context.effective_at,
|
|
details={
|
|
"group_type": resolved.group.group_type,
|
|
"group_key": resolved.group.key,
|
|
"group_revision": resolved.group.revision,
|
|
"source_provider": resolved.group.source_provider,
|
|
"source_resource_type": resolved.group.source_resource_type,
|
|
"source_resource_id": resolved.group.source_resource_id,
|
|
"relationship_kinds": list(relationship_kinds),
|
|
},
|
|
)
|
|
)
|
|
rows: list[DistributionRecipientRef] = []
|
|
for decision in resolved.decisions:
|
|
relationship = decision.relationship
|
|
explanation = _explanation(
|
|
decision.code,
|
|
decision.explanation,
|
|
severity="info" if decision.included else "warning",
|
|
provider="idm",
|
|
source=entry.source,
|
|
)
|
|
provenance = {
|
|
**_entry_provenance(entry),
|
|
"typed_group_id": resolved.group.id,
|
|
"typed_group_key": resolved.group.key,
|
|
"typed_group_type": resolved.group.group_type,
|
|
"typed_group_revision": resolved.group.revision,
|
|
"relationship_id": relationship.id,
|
|
"relationship_kind": relationship.relationship_kind,
|
|
"relationship_revision": relationship.revision,
|
|
"relationship_valid_from": (
|
|
relationship.valid_from.isoformat()
|
|
if relationship.valid_from is not None
|
|
else None
|
|
),
|
|
"relationship_valid_until": (
|
|
relationship.valid_until.isoformat()
|
|
if relationship.valid_until is not None
|
|
else None
|
|
),
|
|
"relationship_source_provider": relationship.source_provider,
|
|
"relationship_source_resource_type": relationship.source_resource_type,
|
|
"relationship_source_resource_id": relationship.source_resource_id,
|
|
"relationship_source_revision": relationship.source_revision,
|
|
"relationship_properties": dict(relationship.properties),
|
|
"relationship_provenance": dict(relationship.provenance),
|
|
"membership_decision": decision.code,
|
|
"membership_effective_at": resolved.effective_at.isoformat(),
|
|
}
|
|
if not decision.included:
|
|
rows.append(
|
|
DistributionRecipientRef(
|
|
recipient_key=f"identity:{relationship.subject_identity_id}",
|
|
display_name=relationship.subject_identity_id,
|
|
status="suppressed",
|
|
identity_id=relationship.subject_identity_id,
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(explanation,),
|
|
provenance=provenance,
|
|
)
|
|
)
|
|
continue
|
|
identity = identities.get_identity(relationship.subject_identity_id)
|
|
if identity is None or identity.status != "active":
|
|
rows.append(
|
|
DistributionRecipientRef(
|
|
recipient_key=f"identity:{relationship.subject_identity_id}",
|
|
display_name=relationship.subject_identity_id,
|
|
status="unresolved",
|
|
identity_id=relationship.subject_identity_id,
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(
|
|
_explanation(
|
|
"identity.not_active",
|
|
"Identity is missing or inactive.",
|
|
provider="identity",
|
|
source=entry.source,
|
|
),
|
|
),
|
|
provenance=provenance,
|
|
)
|
|
)
|
|
continue
|
|
account_id = identity.primary_account_id or next(
|
|
iter(identity.account_ids), None
|
|
)
|
|
if account_id is None:
|
|
rows.append(
|
|
DistributionRecipientRef(
|
|
recipient_key=f"identity:{identity.id}",
|
|
display_name=identity.display_name or identity.id,
|
|
status="unresolved",
|
|
identity_id=identity.id,
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(
|
|
_explanation(
|
|
"identity.no_account",
|
|
"Identity has no linked account.",
|
|
provider="identity",
|
|
source=entry.source,
|
|
),
|
|
),
|
|
provenance=provenance,
|
|
)
|
|
)
|
|
continue
|
|
rows.append(
|
|
DistributionRecipientRef(
|
|
recipient_key=f"identity:{identity.id}",
|
|
display_name=identity.display_name or identity.id,
|
|
status="usable",
|
|
channels=(
|
|
DistributionChannelCandidate(
|
|
channel="internal_mail",
|
|
target=account_id,
|
|
target_key=f"internal_mail:{account_id}",
|
|
source=entry.source,
|
|
decision_provenance={
|
|
"relationship_id": relationship.id,
|
|
"relationship_revision": relationship.revision,
|
|
"decision": decision.code,
|
|
},
|
|
),
|
|
),
|
|
identity_id=identity.id,
|
|
account_id=account_id,
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(explanation,),
|
|
provenance=provenance,
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def _organization_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
) -> list[DistributionRecipientRef]:
|
|
organizations = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
|
OrganizationDirectory,
|
|
)
|
|
incumbencies = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
|
IdmFunctionAssignmentDirectory,
|
|
)
|
|
identities = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_IDENTITY_DIRECTORY,
|
|
IdentityDirectory,
|
|
)
|
|
if organizations is None or incumbencies is None or identities is None:
|
|
return [
|
|
_provider_unavailable(
|
|
entry,
|
|
"Organizations, IDM incumbency, and Identity capabilities are required.",
|
|
)
|
|
]
|
|
if entry.kind == "organization_unit":
|
|
unit = organizations.get_organization_unit(entry.source.resource_id)
|
|
if unit is None or unit.tenant_id != context.principal.tenant_id:
|
|
return [_unresolved(entry, "organization.unit_not_found", "Organization unit not found.")]
|
|
functions = organizations.functions_for_organization_unit(
|
|
unit.id,
|
|
include_subunits=bool(entry.configuration.get("include_subunits", False)),
|
|
)
|
|
function_ids = [item.id for item in functions if item.status == "active"]
|
|
else:
|
|
function = organizations.get_function(entry.source.resource_id)
|
|
if function is None or function.tenant_id != context.principal.tenant_id:
|
|
return [_unresolved(entry, "organization.function_not_found", "Organization function not found.")]
|
|
function_ids = [function.id]
|
|
results: list[DistributionRecipientRef] = []
|
|
batch = incumbencies.organization_function_incumbencies(
|
|
function_ids,
|
|
tenant_id=context.principal.tenant_id,
|
|
effective_at=context.effective_at,
|
|
)
|
|
for function_id in function_ids:
|
|
incumbency = batch.get(function_id)
|
|
if incumbency is None or incumbency.vacant:
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"function.vacant",
|
|
f"Function {function_id} has no effective incumbent.",
|
|
provider="idm",
|
|
source=entry.source,
|
|
)
|
|
)
|
|
continue
|
|
identity_ids = [assignment.identity_id for assignment in incumbency.assignments]
|
|
account_by_identity = {
|
|
assignment.identity_id: assignment.account_id
|
|
for assignment in incumbency.assignments
|
|
if assignment.account_id
|
|
}
|
|
for identity in identities.identities_for_accounts(
|
|
[item for item in account_by_identity.values() if item]
|
|
):
|
|
account_id = account_by_identity.get(identity.id) or identity.primary_account_id
|
|
if account_id is None:
|
|
continue
|
|
results.append(
|
|
DistributionRecipientRef(
|
|
recipient_key=f"identity:{identity.id}",
|
|
display_name=identity.display_name or identity.id,
|
|
status="usable",
|
|
channels=(
|
|
DistributionChannelCandidate(
|
|
channel="internal_mail",
|
|
target=account_id,
|
|
target_key=f"internal_mail:{account_id}",
|
|
source=entry.source,
|
|
),
|
|
),
|
|
identity_id=identity.id,
|
|
account_id=account_id,
|
|
function_id=function_id,
|
|
source_entry_ids=(entry.id,),
|
|
provenance=_entry_provenance(entry),
|
|
)
|
|
)
|
|
missing_ids = set(identity_ids) - {item.identity_id for item in results if item.function_id == function_id}
|
|
for identity_id in sorted(missing_ids):
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"identity.account_unresolved",
|
|
f"Incumbent identity {identity_id} has no resolvable account.",
|
|
provider="identity",
|
|
source=entry.source,
|
|
)
|
|
)
|
|
return results or [_unresolved(entry, "function.no_recipients", "No effective function recipient could be resolved.")]
|
|
|
|
|
|
def _dataflow_recipients(
|
|
context: _ExpansionContext,
|
|
entry: DistributionListEntryRef,
|
|
*,
|
|
parameters: Mapping[str, object],
|
|
) -> list[DistributionRecipientRef]:
|
|
provider = dataflow_dataset_output(context.registry)
|
|
if provider is None:
|
|
return [_provider_unavailable(entry, "Dataflow dataset output is unavailable.")]
|
|
try:
|
|
revision = int(entry.source.revision or entry.configuration.get("revision") or 0)
|
|
except (TypeError, ValueError):
|
|
revision = 0
|
|
if revision < 1:
|
|
return [_unresolved(entry, "dataflow.revision_required", "A pinned Dataflow revision is required.")]
|
|
configured_parameters = entry.configuration.get("parameters")
|
|
request_parameters = (
|
|
dict(configured_parameters)
|
|
if isinstance(configured_parameters, Mapping)
|
|
else dict(parameters)
|
|
)
|
|
expected_source_fingerprints = entry.configuration.get(
|
|
"expected_source_fingerprints"
|
|
)
|
|
expected_fingerprints = (
|
|
tuple(
|
|
dict(item)
|
|
for item in expected_source_fingerprints
|
|
if isinstance(item, Mapping)
|
|
)
|
|
if isinstance(expected_source_fingerprints, Sequence)
|
|
and not isinstance(expected_source_fingerprints, (str, bytes))
|
|
else ()
|
|
)
|
|
try:
|
|
result = provider.read_output(
|
|
context.session,
|
|
context.principal,
|
|
request=DataflowDatasetRequest(
|
|
pipeline_ref=entry.source.resource_id,
|
|
revision=revision,
|
|
parameters=request_parameters,
|
|
row_limit=context.request.limits.max_provider_results,
|
|
expected_definition_hash=entry.source.fingerprint,
|
|
expected_source_fingerprints=expected_fingerprints,
|
|
),
|
|
)
|
|
except (LookupError, PermissionError, ValueError) as exc:
|
|
return [_unresolved(entry, "dataflow.output_failed", str(exc))]
|
|
stale = _is_stale(
|
|
entry.source,
|
|
actual_revision=str(result.revision),
|
|
actual_fingerprint=result.definition_hash,
|
|
)
|
|
context.evidence.append(
|
|
DistributionProviderEvidence(
|
|
provider="dataflow",
|
|
source=entry.source,
|
|
actual_revision=str(result.revision),
|
|
actual_fingerprint=result.definition_hash,
|
|
stale=stale,
|
|
generated_at=result.generated_at,
|
|
details={
|
|
"output_hash": result.output_hash,
|
|
"run_ref": result.run_ref,
|
|
"source_fingerprints": [dict(item) for item in result.source_fingerprints],
|
|
},
|
|
)
|
|
)
|
|
return [
|
|
_dataflow_row_recipient(entry, row, index=index, stale=stale)
|
|
for index, row in enumerate(result.rows)
|
|
]
|
|
|
|
|
|
def _dataflow_row_recipient(
|
|
entry: DistributionListEntryRef,
|
|
row: Mapping[str, object],
|
|
*,
|
|
index: int,
|
|
stale: bool,
|
|
) -> DistributionRecipientRef:
|
|
fields = entry.configuration.get("field_map")
|
|
field_map = dict(fields) if isinstance(fields, Mapping) else {}
|
|
|
|
def value(name: str, default: str) -> str | None:
|
|
key = str(field_map.get(name) or default)
|
|
return _text(row.get(key))
|
|
|
|
def raw_value(name: str, default: str) -> object:
|
|
key = str(field_map.get(name) or default)
|
|
return row.get(key)
|
|
|
|
recipient_key = value("recipient_key", "recipient_key") or f"dataflow:{entry.id}:{index}"
|
|
display_name = value("display_name", "display_name") or recipient_key
|
|
explicit_status = value("status", "distribution_status")
|
|
if explicit_status not in {
|
|
"usable",
|
|
"unresolved",
|
|
"invalid",
|
|
"suppressed",
|
|
"ambiguous",
|
|
"duplicate",
|
|
"policy_blocked",
|
|
"provider_unavailable",
|
|
"stale",
|
|
}:
|
|
explicit_status = None
|
|
reason_code = value("reason_code", "exclusion_reason")
|
|
explanation_text = value("explanation", "exclusion_explanation") or reason_code
|
|
selected_channel = value("selected_channel", "selected_channel")
|
|
generic_contact_point_id = value("contact_point_id", "contact_point_id")
|
|
locale = value("locale", "locale")
|
|
channels: list[DistributionChannelCandidate] = []
|
|
for channel, default_field in (
|
|
("email", "email"),
|
|
("postal", "postal_address"),
|
|
("internal_mail", "account_id"),
|
|
("portal", "portal_target"),
|
|
):
|
|
target = value(channel, default_field)
|
|
if target:
|
|
candidate_status = (
|
|
"stale"
|
|
if stale and explicit_status in {None, "usable"}
|
|
else explicit_status or "usable"
|
|
)
|
|
contact_point_id = value(
|
|
f"{channel}_contact_point_id",
|
|
f"{default_field}_contact_point_id",
|
|
)
|
|
if contact_point_id is None and selected_channel == channel:
|
|
contact_point_id = generic_contact_point_id
|
|
channels.append(
|
|
DistributionChannelCandidate(
|
|
channel=channel, # type: ignore[arg-type]
|
|
target=target,
|
|
target_key=f"{channel}:{_normalized_target(target)}",
|
|
status=candidate_status, # type: ignore[arg-type]
|
|
contact_point_id=contact_point_id,
|
|
locale=locale,
|
|
preferred=bool(
|
|
raw_value(f"{channel}_preferred", f"{channel}_preferred")
|
|
or selected_channel == channel
|
|
),
|
|
reason_code=(
|
|
"source.stale"
|
|
if candidate_status == "stale"
|
|
else reason_code
|
|
),
|
|
explanation=explanation_text,
|
|
source=entry.source,
|
|
decision_provenance={
|
|
"dataflow_policy_decision": value(
|
|
"policy_decision",
|
|
"policy_decision",
|
|
),
|
|
},
|
|
)
|
|
)
|
|
status = (
|
|
explicit_status
|
|
or ("stale" if stale else ("usable" if channels else "unresolved"))
|
|
)
|
|
if status == "usable" and not channels:
|
|
status = "unresolved"
|
|
explanations = (
|
|
(
|
|
_explanation(
|
|
reason_code or f"dataflow.{status}",
|
|
explanation_text or f"Dataflow classified this recipient as {status}.",
|
|
severity="info" if status in {"usable", "duplicate"} else "warning",
|
|
provider="dataflow",
|
|
source=entry.source,
|
|
),
|
|
)
|
|
if status != "usable" or explanation_text
|
|
else ()
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=recipient_key,
|
|
display_name=display_name,
|
|
status=status, # type: ignore[arg-type]
|
|
channels=tuple(channels),
|
|
identity_id=value("identity_id", "identity_id"),
|
|
account_id=value("account_id", "account_id"),
|
|
contact_id=value("contact_id", "contact_id"),
|
|
organization_unit_id=value("organization_unit_id", "organization_unit_id"),
|
|
function_id=value("function_id", "function_id"),
|
|
source_entry_ids=(entry.id,),
|
|
explanations=explanations,
|
|
attributes=dict(row),
|
|
provenance={
|
|
**_entry_provenance(entry),
|
|
"dataflow_row_index": index,
|
|
"override_reason": _text(entry.configuration.get("override_reason")),
|
|
},
|
|
)
|
|
|
|
|
|
def _apply_channel_decisions(
|
|
context: _ExpansionContext,
|
|
distribution_list: DistributionList,
|
|
revision: DistributionListRevision,
|
|
recipient: DistributionRecipientRef,
|
|
) -> DistributionRecipientRef:
|
|
channels = list(recipient.channels)
|
|
facts_applied = False
|
|
facts_provider = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
|
RecipientChannelFactsProvider,
|
|
)
|
|
if (
|
|
facts_provider is not None
|
|
and recipient.source_entry_ids
|
|
and not recipient.provenance.get("channel_facts_resolved")
|
|
):
|
|
source = channels[0].source if channels else None
|
|
if source is not None:
|
|
facts = facts_provider.resolve_channel_facts(
|
|
context.session,
|
|
context.principal,
|
|
request=RecipientChannelFactsRequest(
|
|
tenant_id=context.principal.tenant_id,
|
|
source=source,
|
|
recipient_key=recipient.recipient_key,
|
|
effective_at=context.effective_at,
|
|
purpose=context.request.purpose,
|
|
requested_channels=context.request.requested_channels,
|
|
context={"list_id": distribution_list.id},
|
|
),
|
|
)
|
|
channels = list(facts.candidates)
|
|
facts_applied = True
|
|
recipient = replace(
|
|
recipient,
|
|
explanations=(*recipient.explanations, *facts.explanations),
|
|
provenance={
|
|
**dict(recipient.provenance),
|
|
"channel_facts": dict(facts.provenance),
|
|
},
|
|
)
|
|
|
|
raw_requested_by_entry = recipient.provenance.get("requested_channels", ())
|
|
requested_by_entry = (
|
|
{str(item) for item in raw_requested_by_entry if str(item)}
|
|
if isinstance(raw_requested_by_entry, (list, tuple, set, frozenset))
|
|
else set()
|
|
)
|
|
requested_by_caller = set(context.request.requested_channels)
|
|
has_channel_constraint = bool(requested_by_entry or requested_by_caller)
|
|
requested_channels = (
|
|
requested_by_entry.intersection(requested_by_caller)
|
|
if requested_by_entry and requested_by_caller
|
|
else requested_by_entry or requested_by_caller
|
|
)
|
|
if has_channel_constraint:
|
|
channels = [
|
|
item
|
|
if item.channel in requested_channels
|
|
or item.status not in {"usable", "stale"}
|
|
else replace(
|
|
item,
|
|
status="suppressed",
|
|
reason_code="channel.not_requested",
|
|
explanation="This channel was not requested for the expansion.",
|
|
)
|
|
for item in channels
|
|
]
|
|
|
|
eligible = [item for item in channels if item.status in {"usable", "stale"}]
|
|
if not facts_applied and len(eligible) > 1:
|
|
default_channel = str(revision.constraints.get("default_channel") or "email")
|
|
preferred = next(
|
|
(item for item in eligible if item.channel == default_channel),
|
|
eligible[0],
|
|
)
|
|
channels = [
|
|
item
|
|
if item.target_key == preferred.target_key
|
|
or item.status not in {"usable", "stale"}
|
|
else replace(
|
|
item,
|
|
status="suppressed",
|
|
reason_code="channel.default_not_selected",
|
|
explanation=(
|
|
f"The configured {default_channel} channel was selected "
|
|
"because no preference facts are available."
|
|
),
|
|
)
|
|
for item in channels
|
|
]
|
|
if not context.channel_facts_unavailable_reported:
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"channel_facts.unavailable",
|
|
"Channel preference facts are unavailable; only the configured default channel is used.",
|
|
severity="info",
|
|
provider="addresses",
|
|
)
|
|
)
|
|
context.channel_facts_unavailable_reported = True
|
|
|
|
policy = _typed_capability(
|
|
context.registry,
|
|
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
|
DistributionChannelPolicyProvider,
|
|
)
|
|
governed: list[DistributionChannelCandidate] = []
|
|
for candidate in channels:
|
|
if candidate.status not in {"usable", "stale"}:
|
|
governed.append(candidate)
|
|
continue
|
|
if policy is None:
|
|
governed.append(candidate)
|
|
continue
|
|
decision = policy.resolve_distribution_channel(
|
|
context.session,
|
|
context.principal,
|
|
request=DistributionChannelPolicyRequest(
|
|
tenant_id=context.principal.tenant_id,
|
|
list_id=distribution_list.id,
|
|
purpose=context.request.purpose,
|
|
effective_at=context.effective_at,
|
|
recipient=recipient,
|
|
candidate=candidate,
|
|
context={"definition_hash": revision.definition_hash},
|
|
),
|
|
)
|
|
governed.append(
|
|
replace(
|
|
candidate,
|
|
status=candidate.status if decision.allowed else "policy_blocked",
|
|
reason_code=decision.reason_code,
|
|
explanation=decision.explanation,
|
|
decision_provenance={
|
|
"source_path": [dict(item) for item in decision.source_path],
|
|
"requirements": list(decision.requirements),
|
|
"details": dict(decision.details),
|
|
},
|
|
)
|
|
)
|
|
if policy is None and not context.policy_unavailable_reported:
|
|
context.diagnostics.append(
|
|
_explanation(
|
|
"policy.unavailable",
|
|
"Distribution-channel Policy is unavailable; provider facts and configured defaults apply.",
|
|
severity="info",
|
|
provider="policy",
|
|
)
|
|
)
|
|
context.policy_unavailable_reported = True
|
|
status = "usable" if any(item.status == "usable" for item in governed) else _recipient_outcome(governed, recipient.status)
|
|
return replace(recipient, channels=tuple(governed), status=status)
|
|
|
|
|
|
def _deduplicate_recipients(
|
|
recipients: Sequence[DistributionRecipientRef],
|
|
*,
|
|
duplicate_rule: str,
|
|
) -> tuple[list[DistributionRecipientRef], list[DistributionRecipientRef]]:
|
|
grouped: dict[str, DistributionRecipientRef] = {}
|
|
duplicates: list[DistributionRecipientRef] = []
|
|
for recipient in recipients:
|
|
key = _recipient_match_keys(recipient)
|
|
previous = grouped.get(key)
|
|
if previous is None:
|
|
grouped[key] = recipient
|
|
continue
|
|
if duplicate_rule == "group":
|
|
grouped[key] = replace(
|
|
previous,
|
|
channels=tuple(
|
|
{
|
|
item.target_key: item
|
|
for item in (*previous.channels, *recipient.channels)
|
|
}.values()
|
|
),
|
|
source_entry_ids=tuple(
|
|
dict.fromkeys((*previous.source_entry_ids, *recipient.source_entry_ids))
|
|
),
|
|
provenance={
|
|
**dict(previous.provenance),
|
|
"grouped_duplicate_count": int(
|
|
previous.provenance.get("grouped_duplicate_count", 1)
|
|
)
|
|
+ 1,
|
|
},
|
|
)
|
|
continue
|
|
duplicates.append(
|
|
replace(
|
|
recipient,
|
|
status="duplicate",
|
|
channels=tuple(
|
|
replace(
|
|
item,
|
|
status="duplicate",
|
|
reason_code="recipient.duplicate",
|
|
explanation="An earlier recipient resolved to the same target.",
|
|
)
|
|
for item in recipient.channels
|
|
),
|
|
explanations=(
|
|
*recipient.explanations,
|
|
_explanation(
|
|
"recipient.duplicate",
|
|
"An earlier recipient resolved to the same target.",
|
|
severity="info",
|
|
),
|
|
),
|
|
)
|
|
)
|
|
return list(grouped.values()), duplicates
|
|
|
|
|
|
def _freeze_expansion(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
result: DistributionExpansionResult,
|
|
*,
|
|
revision: DistributionListRevision,
|
|
) -> DistributionListSnapshot:
|
|
idempotency_key = result.request.idempotency_key
|
|
assert idempotency_key is not None
|
|
existing = session.scalar(
|
|
select(DistributionListSnapshot).where(
|
|
DistributionListSnapshot.tenant_id == principal.tenant_id,
|
|
DistributionListSnapshot.idempotency_key == idempotency_key,
|
|
)
|
|
)
|
|
request_json = _json_value(asdict(result.request))
|
|
if existing is not None:
|
|
if existing.expansion_hash != result.expansion_hash or existing.request_ != request_json:
|
|
raise DistributionListConflictError(
|
|
"The snapshot idempotency key was already used for a different expansion."
|
|
)
|
|
return existing
|
|
snapshot = DistributionListSnapshot(
|
|
tenant_id=principal.tenant_id,
|
|
distribution_list_id=result.source.id,
|
|
revision_id=revision.id,
|
|
revision_number=revision.revision,
|
|
idempotency_key=idempotency_key,
|
|
request_=request_json,
|
|
expansion_hash=result.expansion_hash,
|
|
effective_at=result.request.effective_at or utc_now(),
|
|
recipient_count=len(result.recipients),
|
|
excluded_count=len(result.excluded),
|
|
recipients=[_json_value(asdict(item)) for item in result.recipients],
|
|
excluded=[_json_value(asdict(item)) for item in result.excluded],
|
|
diagnostics=[_json_value(asdict(item)) for item in result.diagnostics],
|
|
provider_evidence=[
|
|
_json_value(asdict(item)) for item in result.provider_evidence
|
|
],
|
|
stale=result.stale,
|
|
truncated=result.truncated,
|
|
created_by_account_id=principal.account_id,
|
|
provenance={
|
|
"module": "dist_lists",
|
|
"definition_hash": result.source.definition_hash,
|
|
"resource_revision": result.source.provenance.get("resource_revision"),
|
|
},
|
|
)
|
|
session.add(snapshot)
|
|
session.flush()
|
|
return snapshot
|
|
|
|
|
|
def _resolve_parameters(
|
|
schema: Sequence[Mapping[str, object]],
|
|
supplied: Mapping[str, object],
|
|
) -> dict[str, object]:
|
|
definitions = {str(item.get("key")): item for item in schema}
|
|
unknown = sorted(set(supplied) - set(definitions))
|
|
if unknown:
|
|
raise DistributionListConflictError(
|
|
f"Unknown distribution-list parameters: {', '.join(unknown)}."
|
|
)
|
|
result: dict[str, object] = {}
|
|
for key, definition in definitions.items():
|
|
value = supplied.get(key, definition.get("default"))
|
|
if value is None:
|
|
if bool(definition.get("required")):
|
|
raise DistributionListConflictError(f"Parameter {key!r} is required.")
|
|
continue
|
|
value = _coerce_parameter(key, value, str(definition.get("value_type")))
|
|
allowed = definition.get("allowed_values")
|
|
if isinstance(allowed, Sequence) and not isinstance(allowed, (str, bytes)) and allowed and value not in allowed:
|
|
raise DistributionListConflictError(f"Parameter {key!r} has a disallowed value.")
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
minimum = definition.get("minimum")
|
|
maximum = definition.get("maximum")
|
|
if minimum is not None and value < float(minimum):
|
|
raise DistributionListConflictError(f"Parameter {key!r} is below its minimum.")
|
|
if maximum is not None and value > float(maximum):
|
|
raise DistributionListConflictError(f"Parameter {key!r} exceeds its maximum.")
|
|
pattern = _text(definition.get("pattern"))
|
|
if pattern and isinstance(value, str) and re.fullmatch(pattern, value) is None:
|
|
raise DistributionListConflictError(f"Parameter {key!r} does not match its pattern.")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _coerce_parameter(key: str, value: object, value_type: str) -> object:
|
|
try:
|
|
if value_type == "string":
|
|
return str(value)
|
|
if value_type == "integer" and not isinstance(value, bool):
|
|
return int(value)
|
|
if value_type == "number" and not isinstance(value, bool):
|
|
return float(value)
|
|
if value_type == "boolean" and isinstance(value, bool):
|
|
return value
|
|
if value_type == "string_list" and isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
return [str(item) for item in value]
|
|
if value_type == "date":
|
|
return date.fromisoformat(str(value)).isoformat()
|
|
if value_type == "datetime":
|
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).isoformat()
|
|
except (TypeError, ValueError) as exc:
|
|
raise DistributionListConflictError(
|
|
f"Parameter {key!r} is not a valid {value_type}."
|
|
) from exc
|
|
raise DistributionListConflictError(f"Parameter {key!r} is not a valid {value_type}.")
|
|
|
|
|
|
def _substitute_entry(
|
|
entry: DistributionListEntryRef,
|
|
parameters: Mapping[str, object],
|
|
) -> DistributionListEntryRef:
|
|
return replace(
|
|
entry,
|
|
source=replace(
|
|
entry.source,
|
|
resource_id=str(_substitute_value(entry.source.resource_id, parameters)),
|
|
metadata=_substitute_value(entry.source.metadata, parameters),
|
|
),
|
|
label=_substitute_value(entry.label, parameters),
|
|
purpose=_substitute_value(entry.purpose, parameters),
|
|
configuration=_substitute_value(entry.configuration, parameters),
|
|
)
|
|
|
|
|
|
def _substitute_value(value: object, parameters: Mapping[str, object]):
|
|
if isinstance(value, str):
|
|
exact = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}", value)
|
|
if exact:
|
|
return parameters.get(exact.group(1), value)
|
|
return re.sub(
|
|
r"\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}",
|
|
lambda match: str(parameters.get(match.group(1), match.group(0))),
|
|
value,
|
|
)
|
|
if isinstance(value, Mapping):
|
|
return {str(key): _substitute_value(item, parameters) for key, item in value.items()}
|
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
return [_substitute_value(item, parameters) for item in value]
|
|
return value
|
|
|
|
|
|
def _entry_is_effective(entry: DistributionListEntryRef, effective_at: datetime) -> bool:
|
|
if entry.effective_from and _aware(entry.effective_from) > effective_at:
|
|
return False
|
|
return not (entry.effective_until and _aware(entry.effective_until) <= effective_at)
|
|
|
|
|
|
def _provider_unavailable(
|
|
entry: DistributionListEntryRef,
|
|
message: str,
|
|
) -> DistributionRecipientRef:
|
|
return _unresolved(entry, "provider.unavailable", message, status="provider_unavailable")
|
|
|
|
|
|
def _unresolved(
|
|
entry: DistributionListEntryRef,
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
status: str = "unresolved",
|
|
) -> DistributionRecipientRef:
|
|
explanation = _explanation(
|
|
code,
|
|
message,
|
|
severity="error" if status == "invalid" else "warning",
|
|
provider=entry.source.provider,
|
|
source=entry.source,
|
|
)
|
|
return DistributionRecipientRef(
|
|
recipient_key=f"{status}:{entry.id}",
|
|
display_name=entry.label or entry.source.label or entry.source.resource_id,
|
|
status=status, # type: ignore[arg-type]
|
|
source_entry_ids=(entry.id,),
|
|
explanations=(explanation,),
|
|
provenance=_entry_provenance(entry),
|
|
)
|
|
|
|
|
|
def _placeholder_recipient(
|
|
key: str,
|
|
explanation: DistributionExplanation,
|
|
) -> DistributionRecipientRef:
|
|
return DistributionRecipientRef(
|
|
recipient_key=f"invalid:{key}",
|
|
display_name=key,
|
|
status="invalid",
|
|
explanations=(explanation,),
|
|
)
|
|
|
|
|
|
def _entry_provenance(entry: DistributionListEntryRef) -> dict[str, object]:
|
|
return {
|
|
"entry_kind": entry.kind,
|
|
"entry_mode": entry.mode,
|
|
"source_provider": entry.source.provider,
|
|
"source_resource_type": entry.source.resource_type,
|
|
"source_resource_id": entry.source.resource_id,
|
|
"source_revision": entry.source.revision,
|
|
"source_fingerprint": entry.source.fingerprint,
|
|
"requested_channels": list(entry.requested_channels),
|
|
"purpose": entry.purpose,
|
|
"override_reason": _text(entry.configuration.get("override_reason")),
|
|
}
|
|
|
|
|
|
def _recipient_match_keys(recipient: DistributionRecipientRef) -> str:
|
|
channel_keys = sorted(item.target_key for item in recipient.channels)
|
|
return channel_keys[0] if channel_keys else recipient.recipient_key
|
|
|
|
|
|
def _recipient_outcome(
|
|
channels: Sequence[DistributionChannelCandidate],
|
|
fallback: str,
|
|
):
|
|
priority = (
|
|
"policy_blocked",
|
|
"suppressed",
|
|
"invalid",
|
|
"ambiguous",
|
|
"stale",
|
|
"unresolved",
|
|
"provider_unavailable",
|
|
"duplicate",
|
|
)
|
|
statuses = {item.status for item in channels}
|
|
return next((item for item in priority if item in statuses), fallback)
|
|
|
|
|
|
def _is_stale(
|
|
source: DistributionSourceReference,
|
|
*,
|
|
actual_revision: str | None = None,
|
|
actual_fingerprint: str | None = None,
|
|
) -> bool:
|
|
return bool(
|
|
(source.revision and actual_revision and source.revision != actual_revision)
|
|
or (
|
|
source.fingerprint
|
|
and actual_fingerprint
|
|
and source.fingerprint != actual_fingerprint
|
|
)
|
|
)
|
|
|
|
|
|
def _expansion_hash(
|
|
distribution_list: DistributionList,
|
|
revision: DistributionListRevision,
|
|
*,
|
|
request: DistributionExpansionRequest,
|
|
recipients: Sequence[DistributionRecipientRef],
|
|
excluded: Sequence[DistributionRecipientRef],
|
|
evidence: Sequence[DistributionProviderEvidence],
|
|
) -> str:
|
|
request_payload = asdict(request)
|
|
for control_field in ("preview", "freeze", "idempotency_key"):
|
|
request_payload.pop(control_field, None)
|
|
payload = {
|
|
"list_id": distribution_list.id,
|
|
"revision": revision.revision,
|
|
"definition_hash": revision.definition_hash,
|
|
"request": _json_value(request_payload),
|
|
"recipients": [_json_value(asdict(item)) for item in recipients],
|
|
"excluded": [_json_value(asdict(item)) for item in excluded],
|
|
"provider_evidence": [
|
|
_stable_provider_evidence(item) for item in evidence
|
|
],
|
|
}
|
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _stable_provider_evidence(item: DistributionProviderEvidence) -> object:
|
|
payload = asdict(item)
|
|
payload.pop("generated_at", None)
|
|
details = dict(payload.get("details") or {})
|
|
details.pop("run_ref", None)
|
|
payload["details"] = details
|
|
return _json_value(payload)
|
|
|
|
|
|
def _json_value(value):
|
|
if isinstance(value, datetime):
|
|
return _aware(value).isoformat()
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
if isinstance(value, Mapping):
|
|
return {str(key): _json_value(item) for key, item in value.items()}
|
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
return [_json_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _recipient_from_json(value: Mapping[str, object]) -> DistributionRecipientRef:
|
|
return DistributionRecipientRef(
|
|
recipient_key=str(value["recipient_key"]),
|
|
display_name=str(value["display_name"]),
|
|
status=str(value["status"]), # type: ignore[arg-type]
|
|
channels=tuple(_channel_from_json(item) for item in value.get("channels", ())), # type: ignore[arg-type]
|
|
identity_id=_text(value.get("identity_id")),
|
|
account_id=_text(value.get("account_id")),
|
|
contact_id=_text(value.get("contact_id")),
|
|
organization_unit_id=_text(value.get("organization_unit_id")),
|
|
function_id=_text(value.get("function_id")),
|
|
source_entry_ids=tuple(str(item) for item in value.get("source_entry_ids", ())), # type: ignore[arg-type]
|
|
explanations=tuple(_explanation_from_json(item) for item in value.get("explanations", ())), # type: ignore[arg-type]
|
|
attributes=dict(value.get("attributes", {}) or {}), # type: ignore[arg-type]
|
|
provenance=dict(value.get("provenance", {}) or {}), # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _channel_from_json(value: Mapping[str, object]) -> DistributionChannelCandidate:
|
|
source = value.get("source")
|
|
return DistributionChannelCandidate(
|
|
channel=str(value["channel"]), # type: ignore[arg-type]
|
|
target=str(value["target"]),
|
|
target_key=str(value["target_key"]),
|
|
status=str(value.get("status") or "usable"), # type: ignore[arg-type]
|
|
contact_point_id=_text(value.get("contact_point_id")),
|
|
locale=_text(value.get("locale")),
|
|
preferred=bool(value.get("preferred")),
|
|
reason_code=_text(value.get("reason_code")),
|
|
explanation=_text(value.get("explanation")),
|
|
source=_source_from_json(source) if isinstance(source, Mapping) else None,
|
|
decision_provenance=dict(value.get("decision_provenance", {}) or {}), # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _explanation_from_json(value: Mapping[str, object]) -> DistributionExplanation:
|
|
source = value.get("source")
|
|
return DistributionExplanation(
|
|
code=str(value["code"]),
|
|
message=str(value["message"]),
|
|
severity=str(value.get("severity") or "warning"), # type: ignore[arg-type]
|
|
provider=_text(value.get("provider")),
|
|
source=_source_from_json(source) if isinstance(source, Mapping) else None,
|
|
provenance=dict(value.get("provenance", {}) or {}), # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _evidence_from_json(value: Mapping[str, object]) -> DistributionProviderEvidence:
|
|
return DistributionProviderEvidence(
|
|
provider=str(value["provider"]),
|
|
source=_source_from_json(value["source"]), # type: ignore[arg-type]
|
|
actual_revision=_text(value.get("actual_revision")),
|
|
actual_fingerprint=_text(value.get("actual_fingerprint")),
|
|
stale=bool(value.get("stale")),
|
|
generated_at=_parse_datetime(value.get("generated_at")),
|
|
details=dict(value.get("details", {}) or {}), # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _source_from_json(value: Mapping[str, object]) -> DistributionSourceReference:
|
|
return DistributionSourceReference(
|
|
provider=str(value["provider"]),
|
|
resource_type=str(value["resource_type"]),
|
|
resource_id=str(value["resource_id"]),
|
|
revision=_text(value.get("revision")),
|
|
fingerprint=_text(value.get("fingerprint")),
|
|
label=_text(value.get("label")),
|
|
metadata=dict(value.get("metadata", {}) or {}), # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _unique_explanations(
|
|
items: Sequence[DistributionExplanation],
|
|
) -> list[DistributionExplanation]:
|
|
seen: set[tuple[str, str, str | None]] = set()
|
|
result: list[DistributionExplanation] = []
|
|
for item in items:
|
|
key = (item.code, item.message, item.provider)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _unique_evidence(
|
|
items: Sequence[DistributionProviderEvidence],
|
|
) -> list[DistributionProviderEvidence]:
|
|
seen: set[tuple[str, str, str | None, str | None]] = set()
|
|
result: list[DistributionProviderEvidence] = []
|
|
for item in items:
|
|
key = (
|
|
item.provider,
|
|
item.source.resource_id,
|
|
item.actual_revision,
|
|
item.actual_fingerprint,
|
|
)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _explanation(
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
severity: str = "warning",
|
|
provider: str | None = None,
|
|
source: DistributionSourceReference | None = None,
|
|
) -> DistributionExplanation:
|
|
return DistributionExplanation(
|
|
code=code,
|
|
message=message,
|
|
severity=severity, # type: ignore[arg-type]
|
|
provider=provider,
|
|
source=source,
|
|
)
|
|
|
|
|
|
def _capability(registry: object | None, name: str) -> object | None:
|
|
if (
|
|
registry is None
|
|
or not hasattr(registry, "has_capability")
|
|
or not hasattr(registry, "capability")
|
|
or not registry.has_capability(name)
|
|
):
|
|
return None
|
|
return registry.capability(name)
|
|
|
|
|
|
def _typed_capability(registry: object | None, name: str, protocol):
|
|
capability = _capability(registry, name)
|
|
return capability if isinstance(capability, protocol) else None
|
|
|
|
|
|
def _normalized_target(value: str) -> str:
|
|
return " ".join(value.casefold().split())
|
|
|
|
|
|
def _aware(value: datetime) -> datetime:
|
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
|
|
|
|
|
def _parse_datetime(value: object | None) -> datetime | None:
|
|
if isinstance(value, datetime):
|
|
return _aware(value)
|
|
if value:
|
|
try:
|
|
return _aware(datetime.fromisoformat(str(value).replace("Z", "+00:00")))
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _text(value: object | None) -> str | None:
|
|
candidate = str(value).strip() if value is not None else ""
|
|
return candidate or None
|
|
|
|
|
|
__all__ = [
|
|
"expand_distribution_list",
|
|
"get_snapshot_ref",
|
|
"snapshot_ref",
|
|
]
|