From 77f44dfab50364a8012eb66e55d6b132f04df21e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 7 Aug 2026 11:15:50 +0200 Subject: [PATCH] feat: provision SMTP profiles from deployment receipts --- docs/MAIL_HANDBOOK.md | 22 + .../backend/configuration_provider.py | 1221 +++++++++++++++++ src/govoplan_mail/backend/manifest.py | 60 + tests/test_configuration_provider.py | 388 ++++++ 4 files changed, 1691 insertions(+) create mode 100644 src/govoplan_mail/backend/configuration_provider.py create mode 100644 tests/test_configuration_provider.py diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index 04d1691..3e662df 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -43,6 +43,28 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the module registry. Optional consumers provide narrow context through capabilities; Mail does not import their ORM or service implementations. +## Deployment configuration packages + +Mail registers the `mail.configuration` capability for `smtp_profile` +fragments. The provider reads the validated `mail.smtp` entry from the +installer-generated infrastructure capability receipt. Receipt host and port +are authoritative; the generic package workflow asks for missing non-secret +transport fields such as security mode. An external relay may operate without +authentication, or the operator may select an existing credential-envelope id. +Inline usernames, passwords, tokens, and secret values are rejected. + +Tenant scope is the default. A system-scoped profile requires system settings +or governance write authority. The fragment's stable slug is its idempotency +identity: an absent profile is created, an exact profile is skipped, and a +conflicting profile is preserved unless the reviewed fragment explicitly sets +`on_conflict` to `update`. Credential bindings are added idempotently and are +never removed merely because a package omits a credential reference. + +Preflight does not prove SMTP reachability. After apply, use the normal Mail +profile test and Ops health surfaces. If the receipt says SMTP is unavailable, +is invalid, or is not mounted, import is blocked with an operator-facing +resolution instead of creating a partial profile. + ## Interface patterns and unavailable actions Mail uses the platform's shared explorer, connection tree, adaptive form, diff --git a/src/govoplan_mail/backend/configuration_provider.py b/src/govoplan_mail/backend/configuration_provider.py new file mode 100644 index 0000000..404b8ce --- /dev/null +++ b/src/govoplan_mail/backend/configuration_provider.py @@ -0,0 +1,1221 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, replace +import re +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from govoplan_core.core.configuration_packages import ( + ConfigurationApplyResult, + ConfigurationDiagnostic, + ConfigurationExportResult, + ConfigurationExportSelection, + ConfigurationPackageFragment, + ConfigurationPlanItem, + ConfigurationPreflightContext, + ConfigurationPreflightResult, + ConfigurationProvider, + ConfigurationProviderDescription, + ConfigurationRequiredData, +) +from govoplan_core.core.infrastructure_capabilities import ( + InfrastructureCapability, + InfrastructureCapabilityReceipt, +) +from govoplan_core.security.credential_envelopes import ( + CredentialAccessContext, + CredentialEnvelope, + credential_visible_to_context, +) +from govoplan_core.db.session import get_database +from govoplan_mail.backend.config import SmtpConfig, SmtpServerConfig +from govoplan_mail.backend.db.models import ( + MailServerCredentialBinding, + MailServerEndpoint, + MailServerProfile, +) +from govoplan_mail.backend.mail_profiles import ( + create_mail_server_profile, + slugify_profile_name, + update_mail_server_profile, +) +from govoplan_mail.backend.server_hierarchy import ( + bind_mail_credential, + initialize_profile_hierarchy, + mail_server_ref, + sync_default_profile_server, +) + + +MAIL_CONFIGURATION_CAPABILITY = "mail.configuration" +SMTP_PROFILE_FRAGMENT = "smtp_profile" +_SYSTEM_CONFIGURATION_SCOPES = frozenset( + {"system:settings:write", "system:governance:write"} +) +_PAYLOAD_KEYS = frozenset( + { + "capability_id", + "profile", + "smtp", + "credential_envelope_id", + "credential_is_default", + "input_keys", + "on_conflict", + } +) +_PROFILE_KEYS = frozenset( + { + "name", + "slug", + "description", + "scope_type", + "inherit_to_lower_scopes", + } +) +_SMTP_KEYS = frozenset({"host", "port", "security", "timeout_seconds"}) +_INPUT_KEY_FIELDS = frozenset({"host", "port", "security", "credential_envelope_id"}) + + +@dataclass(frozen=True, slots=True) +class _DesiredSmtpProfile: + fragment_ref: str + tenant_id: str | None + scope_type: str + scope_id: str | None + name: str + slug: str + description: str | None + inherit_to_lower_scopes: bool + smtp: SmtpConfig + credential_envelope_id: str | None + credential_is_default: bool + on_conflict: str + capability_source: str + installation_id: str + operator_user_id: str | None + + @property + def smtp_payload(self) -> dict[str, object]: + payload = self.smtp.model_dump(mode="json") + payload.pop("username", None) + payload.pop("password", None) + return payload + + +@dataclass(frozen=True, slots=True) +class _ParsedMailFragment: + payload: Mapping[str, Any] + profile: Mapping[str, Any] + smtp: Mapping[str, Any] + input_keys: Mapping[str, Any] + + +@dataclass(frozen=True, slots=True) +class _ResolvedSmtpInputs: + smtp: SmtpConfig + credential_envelope_id: str | None + + +@dataclass(frozen=True, slots=True) +class _ProfileTarget: + tenant_id: str | None + scope_type: str + scope_id: str | None + name: str + slug: str + description: str | None + inherit_to_lower_scopes: bool + credential_is_default: bool + on_conflict: str + + +class SqlMailConfigurationProvider(ConfigurationProvider): + module_id = "mail" + + def describe(self) -> ConfigurationProviderDescription: + return ConfigurationProviderDescription( + module_id=self.module_id, + fragment_types=(SMTP_PROFILE_FRAGMENT,), + schema_refs={ + SMTP_PROFILE_FRAGMENT: "govoplan/mail/configuration/smtp-profile.v1" + }, + exported_scopes=("system", "tenant"), + ) + + def preflight( + self, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + ) -> ConfigurationPreflightResult: + if fragment.fragment_type != SMTP_PROFILE_FRAGMENT: + return ConfigurationPreflightResult( + diagnostics=(_unsupported(fragment),), + plan=(_blocked_plan(fragment, "Fragment type is unsupported."),), + ) + with get_database().session() as session: + return _preflight_smtp_profile(session, fragment, context) + + def apply( + self, + fragment: ConfigurationPackageFragment, + supplied_data: Mapping[str, Any], + context: ConfigurationPreflightContext, + ) -> ConfigurationApplyResult: + if fragment.fragment_type != SMTP_PROFILE_FRAGMENT: + return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),)) + apply_context = replace(context, supplied_data=supplied_data, dry_run=False) + with get_database().session() as session: + preflight = _preflight_smtp_profile(session, fragment, apply_context) + blockers = tuple( + item for item in preflight.diagnostics if item.severity == "blocker" + ) + if blockers: + return ConfigurationApplyResult(diagnostics=blockers) + desired, diagnostics, _required_data = _desired_smtp_profile( + fragment, + apply_context, + ) + if desired is None: + return ConfigurationApplyResult(diagnostics=tuple(diagnostics)) + result = _apply_smtp_profile(session, desired) + if not any(item.severity == "blocker" for item in result.diagnostics): + session.commit() + return result + + def export( + self, + selection: ConfigurationExportSelection, + context: ConfigurationPreflightContext, + ) -> ConfigurationExportResult: + del context + with get_database().session() as session: + return _export_smtp_profiles(session, selection) + + def health( + self, + import_result: ConfigurationApplyResult, + context: ConfigurationPreflightContext, + ) -> tuple[ConfigurationDiagnostic, ...]: + del context + return tuple( + item for item in import_result.diagnostics if item.severity == "blocker" + ) + + +def _preflight_smtp_profile( + session: Session, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, +) -> ConfigurationPreflightResult: + desired, diagnostics, required_data = _desired_smtp_profile(fragment, context) + if desired is None: + return ConfigurationPreflightResult( + diagnostics=tuple(diagnostics), + required_data=tuple(required_data), + plan=(_blocked_plan(fragment, "SMTP profile inputs are incomplete."),), + ) + profile = _profile_by_identity(session, desired) + if profile is None: + credential_diagnostics = _credential_diagnostics( + session, + desired, + server=None, + ) + diagnostics.extend(credential_diagnostics) + action = "blocked" if _has_blockers(diagnostics) else "create" + return ConfigurationPreflightResult( + diagnostics=tuple(diagnostics), + required_data=tuple(required_data), + plan=( + ConfigurationPlanItem( + action=action, + module_id="mail", + fragment_type=SMTP_PROFILE_FRAGMENT, + fragment_id=desired.fragment_ref, + summary=( + "Create a receipt-bound SMTP profile and optional credential binding." + if action == "create" + else "SMTP profile creation is blocked." + ), + ), + ), + ) + + server = _default_smtp_server(session, profile.id) + credential_diagnostics = _credential_diagnostics( + session, + desired, + server=server, + ) + diagnostics.extend(credential_diagnostics) + changes = _profile_changes(session, profile, server, desired) + if changes and desired.on_conflict == "preserve": + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="mail_configuration_conflict", + message=( + f"Mail profile {desired.slug!r} already exists with different " + f"configuration ({', '.join(changes)})." + ), + module_id="mail", + object_ref=desired.fragment_ref, + resolution=( + "Keep the existing profile, choose another stable slug, or review " + "the package with on_conflict set to update." + ), + ) + ) + if _has_blockers(diagnostics): + action = "blocked" + summary = ( + "Existing SMTP profile is preserved because the reviewed change is blocked." + ) + elif changes: + action = "update" + summary = f"Update receipt-bound SMTP profile fields: {', '.join(changes)}." + else: + action = "skip" + summary = "SMTP profile and credential binding already match the receipt." + return ConfigurationPreflightResult( + diagnostics=tuple(diagnostics), + required_data=tuple(required_data), + plan=( + ConfigurationPlanItem( + action=action, + module_id="mail", + fragment_type=SMTP_PROFILE_FRAGMENT, + fragment_id=desired.fragment_ref, + summary=summary, + ), + ), + ) + + +def _desired_smtp_profile( + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, +) -> tuple[ + _DesiredSmtpProfile | None, + list[ConfigurationDiagnostic], + list[ConfigurationRequiredData], +]: + diagnostics: list[ConfigurationDiagnostic] = [] + parsed = _parse_mail_fragment(fragment, diagnostics) + if parsed is None: + return None, diagnostics, [] + receipt_state = _mail_receipt_capability( + fragment, + context, + parsed.payload, + diagnostics, + ) + if receipt_state is None: + return None, diagnostics, [] + receipt, capability = receipt_state + resolved, required_data = _resolve_smtp_inputs( + fragment, + context, + parsed, + receipt, + capability, + diagnostics, + ) + if resolved is None: + return None, diagnostics, required_data + target = _resolve_profile_target( + fragment, + context, + parsed, + receipt, + diagnostics, + ) + if target is None: + return None, diagnostics, required_data + fragment_ref = ( + fragment.fragment_id or f"{receipt.installation_id}:mail.smtp:{target.slug}" + ) + return ( + _DesiredSmtpProfile( + fragment_ref=fragment_ref, + tenant_id=target.tenant_id, + scope_type=target.scope_type, + scope_id=target.scope_id, + name=target.name, + slug=target.slug, + description=target.description, + inherit_to_lower_scopes=target.inherit_to_lower_scopes, + smtp=resolved.smtp, + credential_envelope_id=resolved.credential_envelope_id, + credential_is_default=target.credential_is_default, + on_conflict=target.on_conflict, + capability_source=capability.source, + installation_id=receipt.installation_id, + operator_user_id=context.operator_user_id, + ), + diagnostics, + required_data, + ) + + +def _parse_mail_fragment( + fragment: ConfigurationPackageFragment, + diagnostics: list[ConfigurationDiagnostic], +) -> _ParsedMailFragment | None: + payload = fragment.payload + if not isinstance(payload, Mapping): + diagnostics.append( + _invalid(fragment, "SMTP profile payload must be an object.") + ) + return None + unknown_payload = sorted(set(payload) - _PAYLOAD_KEYS) + if unknown_payload: + diagnostics.append(_unknown_payload_diagnostic(fragment, unknown_payload)) + profile = _mapping( + payload.get("profile"), + field_name="profile", + fragment=fragment, + diagnostics=diagnostics, + ) + smtp = _mapping( + payload.get("smtp"), + field_name="smtp", + fragment=fragment, + diagnostics=diagnostics, + ) + input_keys = _mapping( + payload.get("input_keys"), + field_name="input_keys", + fragment=fragment, + diagnostics=diagnostics, + ) + _unknown_fields(profile, _PROFILE_KEYS, "profile", fragment, diagnostics) + unknown_smtp = sorted(set(smtp) - _SMTP_KEYS) + if unknown_smtp: + diagnostics.append(_unknown_smtp_diagnostic(fragment, unknown_smtp)) + _unknown_fields(input_keys, _INPUT_KEY_FIELDS, "input_keys", fragment, diagnostics) + if _has_blockers(diagnostics): + return None + return _ParsedMailFragment( + payload=payload, + profile=profile, + smtp=smtp, + input_keys=input_keys, + ) + + +def _mail_receipt_capability( + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + payload: Mapping[str, Any], + diagnostics: list[ConfigurationDiagnostic], +) -> tuple[InfrastructureCapabilityReceipt, InfrastructureCapability] | None: + if context.infrastructure_receipt_error: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_receipt_invalid", + message=f"The deployment capability receipt is invalid: {context.infrastructure_receipt_error}", + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Repair or regenerate the deployment receipt before importing Mail configuration.", + ) + ) + return None + receipt = context.infrastructure_receipt + if receipt is None: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_receipt_missing", + message="Mail SMTP configuration requires the mounted deployment capability receipt.", + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Mount the installer-generated capability receipt and rerun preflight.", + ) + ) + return None + capability_id = _text(payload.get("capability_id")) or "mail.smtp" + if capability_id != "mail.smtp": + diagnostics.append( + _invalid( + fragment, + "Mail SMTP fragments must reference capability 'mail.smtp'.", + ) + ) + return None + capability = receipt.capability(capability_id) + if capability is None: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_capability_missing", + message="The deployment receipt does not declare Mail SMTP infrastructure.", + module_id="mail", + object_ref=capability_id, + resolution="Regenerate the receipt from a deployment profile that declares Mail infrastructure.", + ) + ) + return None + if capability.state == "unavailable": + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_capability_unavailable", + message="The deployment receipt states that SMTP infrastructure is unavailable.", + module_id="mail", + object_ref=capability_id, + resolution="Reconfigure the deployment with test mail or an external SMTP relay before importing this profile.", + ) + ) + return None + return receipt, capability + + +def _resolve_smtp_inputs( + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + parsed: _ParsedMailFragment, + receipt: InfrastructureCapabilityReceipt, + capability: InfrastructureCapability, + diagnostics: list[ConfigurationDiagnostic], +) -> tuple[_ResolvedSmtpInputs | None, list[ConfigurationRequiredData]]: + prefix = _input_prefix(fragment, receipt.installation_id) + key_map = { + field: _text(parsed.input_keys.get(field)) or f"{prefix}.{field}" + for field in _INPUT_KEY_FIELDS + } + endpoint = capability.endpoint + values = { + field: _receipt_or_input_value( + field=field, + receipt_value=endpoint.get(field), + payload=parsed.smtp, + supplied_data=context.supplied_data, + input_key=key_map[field], + fragment=fragment, + diagnostics=diagnostics, + ) + for field in ("host", "port", "security") + } + if values["security"] is None: + values["security"] = _inferred_security( + source=capability.source, + scheme=_text(endpoint.get("scheme")), + port=values["port"], + ) + required_data = _smtp_required_data(values, key_map) + credential_envelope_id = _text( + parsed.payload.get("credential_envelope_id") + ) or _text(context.supplied_data.get(key_map["credential_envelope_id"])) + credential_required = bool(capability.secret_refs) + required_data.append( + ConfigurationRequiredData( + key=key_map["credential_envelope_id"], + label="SMTP credential envelope id", + data_type="reference:credential-envelope", + required=credential_required and credential_envelope_id is None, + secret=False, + description=( + "An existing credential envelope is required by the receipt; plaintext credentials are not accepted." + if credential_required + else "Optional existing credential envelope; plaintext credentials are not accepted." + ), + ) + ) + missing = [item for item in required_data if item.required] + if missing: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="mail_configuration_input_required", + message=( + "Mail SMTP configuration still needs reviewed operator data: " + + ", ".join(item.label for item in missing) + + "." + ), + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Provide the listed non-secret values or credential-envelope reference and rerun preflight.", + ) + ) + return None, required_data + try: + smtp = SmtpConfig.model_validate( + { + **values, + "timeout_seconds": parsed.smtp.get("timeout_seconds", 30), + } + ) + except Exception as exc: + diagnostics.append( + _invalid(fragment, f"SMTP server metadata is invalid: {exc}") + ) + return None, required_data + return ( + _ResolvedSmtpInputs( + smtp=smtp, + credential_envelope_id=credential_envelope_id, + ), + required_data, + ) + + +def _resolve_profile_target( + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + parsed: _ParsedMailFragment, + receipt: InfrastructureCapabilityReceipt, + diagnostics: list[ConfigurationDiagnostic], +) -> _ProfileTarget | None: + scope_type = (_text(parsed.profile.get("scope_type")) or "tenant").casefold() + if scope_type not in {"system", "tenant"}: + diagnostics.append( + _invalid( + fragment, + "Deployment SMTP profiles may use only system or tenant scope.", + ) + ) + return None + if scope_type == "tenant" and not context.tenant_id: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="tenant_required", + message="A tenant-scoped Mail profile requires a selected tenant.", + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Select a tenant or review a system-scoped package with system authority.", + ) + ) + return None + if scope_type == "system" and not _SYSTEM_CONFIGURATION_SCOPES.intersection( + context.operator_scopes + ): + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="system_configuration_authority_required", + message="Creating a system-wide Mail profile requires system configuration authority.", + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Use a tenant profile or ask a system administrator to apply the package.", + ) + ) + return None + on_conflict = (_text(parsed.payload.get("on_conflict")) or "preserve").casefold() + if on_conflict not in {"preserve", "update"}: + diagnostics.append( + _invalid(fragment, "on_conflict must be 'preserve' or 'update'.") + ) + return None + name = _text(parsed.profile.get("name")) or ( + f"Deployment SMTP ({receipt.installation_id})" + ) + slug = slugify_profile_name( + _text(parsed.profile.get("slug")) + or f"deployment-{receipt.installation_id}-smtp" + )[:100] + return _ProfileTarget( + tenant_id=None if scope_type == "system" else context.tenant_id, + scope_type=scope_type, + scope_id=None if scope_type == "system" else context.tenant_id, + name=name[:255], + slug=slug, + description=( + _text(parsed.profile.get("description")) + or f"SMTP profile bound to deployment capability receipt {receipt.installation_id}." + ), + inherit_to_lower_scopes=_bool( + parsed.profile.get("inherit_to_lower_scopes"), + default=True, + ), + credential_is_default=_bool( + parsed.payload.get("credential_is_default"), + default=True, + ), + on_conflict=on_conflict, + ) + + +def _apply_smtp_profile( + session: Session, + desired: _DesiredSmtpProfile, +) -> ConfigurationApplyResult: + profile = _profile_by_identity(session, desired) + created: dict[str, str] = {} + updated: dict[str, str] = {} + if profile is None: + profile = create_mail_server_profile( + session, + tenant_id=desired.tenant_id or "system", + user_id=desired.operator_user_id, + name=desired.name, + slug=desired.slug, + description=desired.description, + smtp=desired.smtp, + imap=None, + is_active=True, + inherit_to_lower_scopes=desired.inherit_to_lower_scopes, + scope_type=desired.scope_type, + scope_id=desired.scope_id, + ) + initialize_profile_hierarchy( + session, + profile=profile, + smtp=desired.smtp, + imap=None, + user_id=desired.operator_user_id, + ) + created[desired.fragment_ref] = f"mail_profile:{profile.id}" + else: + server = _default_smtp_server(session, profile.id) + changes = _profile_changes(session, profile, server, desired) + if changes: + update_mail_server_profile( + session, + profile, + user_id=desired.operator_user_id, + tenant_id=desired.tenant_id or "system", + name=desired.name, + description=desired.description, + is_active=True if not profile.is_active else None, + inherit_to_lower_scopes=desired.inherit_to_lower_scopes, + smtp=desired.smtp, + ) + sync_default_profile_server( + session, + profile=profile, + protocol="smtp", + config=desired.smtp_payload, + user_id=desired.operator_user_id, + ) + updated[desired.fragment_ref] = f"mail_profile:{profile.id}" + server = _default_smtp_server(session, profile.id) + if server is None: + server = sync_default_profile_server( + session, + profile=profile, + protocol="smtp", + config=desired.smtp_payload, + user_id=desired.operator_user_id, + ) + if desired.credential_envelope_id and server is not None: + credential = session.get(CredentialEnvelope, desired.credential_envelope_id) + if credential is None: + return ConfigurationApplyResult( + diagnostics=( + ConfigurationDiagnostic( + severity="blocker", + code="mail_credential_envelope_missing", + message="The reviewed SMTP credential envelope no longer exists.", + module_id="mail", + object_ref=desired.fragment_ref, + resolution="Rerun preflight and select an available credential envelope.", + ), + ) + ) + binding = _credential_binding( + session, + server.id, + desired.credential_envelope_id, + ) + was_default = bool(binding and binding.is_default) + bind_mail_credential( + session, + server=server, + credential=credential, + user_id=desired.operator_user_id, + is_default=desired.credential_is_default, + ) + if binding is None or (desired.credential_is_default and not was_default): + updated.setdefault( + desired.fragment_ref, + f"mail_profile:{profile.id}", + ) + return ConfigurationApplyResult(created_refs=created, updated_refs=updated) + + +def _export_smtp_profiles( + session: Session, + selection: ConfigurationExportSelection, +) -> ConfigurationExportResult: + clauses = [] + if selection.tenant_id: + clauses.append( + (MailServerProfile.scope_type == "tenant") + & (MailServerProfile.scope_id == selection.tenant_id) + ) + if "system" in selection.scopes: + clauses.append(MailServerProfile.scope_type == "system") + if not clauses: + return ConfigurationExportResult( + diagnostics=( + ConfigurationDiagnostic( + severity="blocker", + code="tenant_required", + message="Mail configuration export requires a tenant or system scope.", + module_id="mail", + resolution="Select a tenant or explicitly include system scope.", + ), + ) + ) + statement = select(MailServerProfile) + if len(clauses) == 1: + statement = statement.where(clauses[0]) + else: + statement = statement.where(or_(*clauses)) + requested = {item.removeprefix("mail_profile:") for item in selection.object_refs} + if requested: + statement = statement.where(MailServerProfile.id.in_(requested)) + profiles = session.execute(statement.order_by(MailServerProfile.slug)).scalars() + fragments: list[ConfigurationPackageFragment] = [] + requirements: list[ConfigurationRequiredData] = [] + for profile in profiles: + server = _default_smtp_server(session, profile.id) + smtp_payload = dict( + server.config if server is not None else profile.smtp_config or {} + ) + for key in ("username", "password", "enabled"): + smtp_payload.pop(key, None) + fragment_id = f"mail-profile-{profile.slug}" + credential_key = f"mail.smtp.{_safe_key(profile.slug)}.credential_envelope_id" + fragments.append( + ConfigurationPackageFragment( + module_id="mail", + fragment_type=SMTP_PROFILE_FRAGMENT, + fragment_id=fragment_id, + payload={ + "capability_id": "mail.smtp", + "profile": { + "name": profile.name, + "slug": profile.slug, + "description": profile.description, + "scope_type": profile.scope_type, + "inherit_to_lower_scopes": bool( + profile.inherit_to_lower_scopes + ), + }, + "smtp": smtp_payload, + "input_keys": {"credential_envelope_id": credential_key}, + "on_conflict": "preserve", + }, + ) + ) + requirements.append( + ConfigurationRequiredData( + key=credential_key, + label=f"Credential envelope for {profile.name}", + data_type="reference:credential-envelope", + required=False, + description="Select a target-environment credential envelope; credentials are never exported.", + ) + ) + return ConfigurationExportResult( + fragments=tuple(fragments), + data_requirements=tuple(requirements), + ) + + +def _profile_by_identity( + session: Session, + desired: _DesiredSmtpProfile, +) -> MailServerProfile | None: + statement = select(MailServerProfile).where( + MailServerProfile.scope_type == desired.scope_type, + MailServerProfile.slug == desired.slug, + ) + if desired.scope_id is None: + statement = statement.where(MailServerProfile.scope_id.is_(None)) + else: + statement = statement.where(MailServerProfile.scope_id == desired.scope_id) + return session.execute(statement).scalar_one_or_none() + + +def _default_smtp_server( + session: Session, + profile_id: str, +) -> MailServerEndpoint | None: + return ( + session.execute( + select(MailServerEndpoint) + .where( + MailServerEndpoint.profile_id == profile_id, + MailServerEndpoint.protocol == "smtp", + ) + .order_by( + MailServerEndpoint.is_default.desc(), + MailServerEndpoint.is_active.desc(), + MailServerEndpoint.name, + MailServerEndpoint.id, + ) + ) + .scalars() + .first() + ) + + +def _profile_changes( + session: Session, + profile: MailServerProfile, + server: MailServerEndpoint | None, + desired: _DesiredSmtpProfile, +) -> list[str]: + changes = _profile_metadata_changes(profile, desired) + changes.extend(_smtp_server_changes(profile, server, desired)) + changes.extend(_credential_binding_changes(session, server, desired)) + return list(dict.fromkeys(changes)) + + +def _profile_metadata_changes( + profile: MailServerProfile, + desired: _DesiredSmtpProfile, +) -> list[str]: + changes: list[str] = [] + if profile.name != desired.name: + changes.append("name") + if (profile.description or None) != (desired.description or None): + changes.append("description") + if not profile.is_active: + changes.append("active state") + if bool(profile.inherit_to_lower_scopes) != desired.inherit_to_lower_scopes: + changes.append("scope inheritance") + return changes + + +def _smtp_server_changes( + profile: MailServerProfile, + server: MailServerEndpoint | None, + desired: _DesiredSmtpProfile, +) -> list[str]: + changes: list[str] = [] + current_payload = dict( + server.config if server is not None else profile.smtp_config or {} + ) + for key in ("username", "password", "enabled"): + current_payload.pop(key, None) + try: + current_payload = SmtpServerConfig.model_validate(current_payload).model_dump( + mode="json" + ) + except Exception: + changes.append("SMTP server metadata") + else: + if current_payload != desired.smtp_payload: + changes.append("SMTP server metadata") + if server is None: + changes.append("SMTP server hierarchy") + return changes + + +def _credential_binding_changes( + session: Session, + server: MailServerEndpoint | None, + desired: _DesiredSmtpProfile, +) -> list[str]: + if not desired.credential_envelope_id or server is None: + return [] + binding = _credential_binding( + session, + server.id, + desired.credential_envelope_id, + ) + if binding is None: + return ["credential binding"] + if desired.credential_is_default and not binding.is_default: + return ["default credential binding"] + return [] + + +def _credential_diagnostics( + session: Session, + desired: _DesiredSmtpProfile, + *, + server: MailServerEndpoint | None, +) -> list[ConfigurationDiagnostic]: + credential_id = desired.credential_envelope_id + if not credential_id: + return [] + credential = session.get(CredentialEnvelope, credential_id) + if ( + credential is None + or credential.deleted_at is not None + or not credential.is_active + ): + return [ + ConfigurationDiagnostic( + severity="blocker", + code="mail_credential_envelope_missing", + message="The selected SMTP credential envelope is missing or inactive.", + module_id="mail", + object_ref=desired.fragment_ref, + resolution="Select an active credential envelope visible to this profile scope.", + ) + ] + server_ref = mail_server_ref(server.id) if server is not None else None + context = CredentialAccessContext( + tenant_id=desired.tenant_id, + target_scope_type=desired.scope_type, + target_scope_id=desired.scope_id, + module_id="mail", + server_ref=server_ref, + ) + if not credential_visible_to_context(credential, context): + reason = ( + "A server-restricted credential cannot be bound before the package-owned server exists." + if server is None and credential.allowed_server_refs + else "The selected credential is outside the profile scope or is not allowed for Mail and this server." + ) + return [ + ConfigurationDiagnostic( + severity="blocker", + code="mail_credential_envelope_not_available", + message=reason, + module_id="mail", + object_ref=desired.fragment_ref, + resolution="Choose a compatible credential envelope or update its governed scope and allowed references first.", + ) + ] + server_tenant_id = desired.tenant_id + if credential.tenant_id not in {None, server_tenant_id}: + return [ + ConfigurationDiagnostic( + severity="blocker", + code="mail_credential_tenant_mismatch", + message="The credential envelope and SMTP profile belong to different tenants.", + module_id="mail", + object_ref=desired.fragment_ref, + resolution="Choose a system credential or a credential from the selected tenant.", + ) + ] + return [] + + +def _credential_binding( + session: Session, + server_id: str, + credential_id: str, +) -> MailServerCredentialBinding | None: + return session.execute( + select(MailServerCredentialBinding).where( + MailServerCredentialBinding.server_id == server_id, + MailServerCredentialBinding.credential_id == credential_id, + ) + ).scalar_one_or_none() + + +def _receipt_or_input_value( + *, + field: str, + receipt_value: object, + payload: Mapping[str, Any], + supplied_data: Mapping[str, Any], + input_key: str, + fragment: ConfigurationPackageFragment, + diagnostics: list[ConfigurationDiagnostic], +) -> object: + explicit = payload.get(field) + if receipt_value is not None: + if explicit is not None and str(explicit) != str(receipt_value): + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_endpoint_mismatch", + message=f"SMTP {field} conflicts with the deployment capability receipt.", + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Use the receipt endpoint or regenerate the deployment receipt before importing.", + ) + ) + return receipt_value + if explicit is not None: + return explicit + return supplied_data.get(input_key) + + +def _inferred_security(*, source: str, scheme: str | None, port: object) -> str | None: + if source == "installer-managed-test": + return "plain" + if scheme in {"smtps", "smtp+tls"} or str(port) == "465": + return "tls" + return None + + +def _smtp_required_data( + values: Mapping[str, object], + key_map: Mapping[str, str], +) -> list[ConfigurationRequiredData]: + requirements: list[ConfigurationRequiredData] = [] + definitions = ( + ("host", "SMTP host", "string", "Non-secret SMTP server hostname."), + ("port", "SMTP port", "integer", "SMTP server port from 1 to 65535."), + ( + "security", + "SMTP transport security", + "enum:plain,starttls,tls", + "Choose plain, STARTTLS, or implicit TLS.", + ), + ) + for field, label, data_type, description in definitions: + value = values.get(field) + if value is None or (isinstance(value, str) and not value.strip()): + requirements.append( + ConfigurationRequiredData( + key=key_map[field], + label=label, + data_type=data_type, + description=description, + ) + ) + return requirements + + +def _unknown_payload_diagnostic( + fragment: ConfigurationPackageFragment, + unknown: list[str], +) -> ConfigurationDiagnostic: + if _contains_secret_field(unknown): + return ConfigurationDiagnostic( + severity="blocker", + code="mail_configuration_secret_forbidden", + message="SMTP configuration packages accept credential-envelope references, never inline credentials.", + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Remove inline credentials and select an existing credential envelope by id.", + ) + return _invalid( + fragment, + f"SMTP profile payload contains unsupported fields: {', '.join(unknown)}.", + ) + + +def _unknown_smtp_diagnostic( + fragment: ConfigurationPackageFragment, + unknown: list[str], +) -> ConfigurationDiagnostic: + if _contains_secret_field(unknown): + return ConfigurationDiagnostic( + severity="blocker", + code="mail_configuration_secret_forbidden", + message=( + "SMTP configuration packages accept non-secret server metadata " + "and credential-envelope references only." + ), + module_id="mail", + object_ref=fragment.fragment_id or SMTP_PROFILE_FRAGMENT, + resolution="Remove inline credentials and select an existing credential envelope by id.", + ) + return _invalid( + fragment, + f"SMTP payload contains unsupported fields: {', '.join(unknown)}.", + ) + + +def _contains_secret_field(fields: list[str]) -> bool: + return any( + marker in field.casefold() + for field in fields + for marker in ("password", "secret", "token", "username") + ) + + +def _mapping( + value: object, + *, + field_name: str, + fragment: ConfigurationPackageFragment, + diagnostics: list[ConfigurationDiagnostic], +) -> Mapping[str, Any]: + if value is None: + return {} + if isinstance(value, Mapping): + return value + diagnostics.append(_invalid(fragment, f"{field_name} must be an object.")) + return {} + + +def _unknown_fields( + value: Mapping[str, Any], + allowed: frozenset[str], + field_name: str, + fragment: ConfigurationPackageFragment, + diagnostics: list[ConfigurationDiagnostic], +) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + diagnostics.append( + _invalid( + fragment, + f"{field_name} contains unsupported fields: {', '.join(unknown)}.", + ) + ) + + +def _input_prefix(fragment: ConfigurationPackageFragment, installation_id: str) -> str: + return f"mail.smtp.{_safe_key(fragment.fragment_id or installation_id)}" + + +def _safe_key(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-.") or "deployment" + + +def _text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _bool(value: object, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().casefold() in {"1", "true", "yes", "on"} + + +def _has_blockers(diagnostics: list[ConfigurationDiagnostic]) -> bool: + return any(item.severity == "blocker" for item in diagnostics) + + +def _invalid( + fragment: ConfigurationPackageFragment, + message: str, +) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="mail_configuration_payload_invalid", + message=message, + module_id="mail", + object_ref=fragment.fragment_id or fragment.fragment_type, + resolution="Review the Mail configuration-package fragment and rerun preflight.", + ) + + +def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="fragment_type_unsupported", + message=f"Mail configuration does not support fragment type {fragment.fragment_type!r}.", + module_id="mail", + object_ref=fragment.fragment_id or fragment.fragment_type, + ) + + +def _blocked_plan( + fragment: ConfigurationPackageFragment, + summary: str, +) -> ConfigurationPlanItem: + return ConfigurationPlanItem( + action="blocked", + module_id="mail", + fragment_type=fragment.fragment_type, + fragment_id=fragment.fragment_id, + summary=summary, + ) + + +__all__ = ["MAIL_CONFIGURATION_CAPABILITY", "SqlMailConfigurationProvider"] diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index a9ade49..a9e1b96 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -40,6 +40,7 @@ from govoplan_core.core.provider_governance import ( from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base +from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY from govoplan_mail.backend.documentation import ( documentation_configuration_states, documentation_topics, @@ -95,6 +96,13 @@ def _mail_retirement_provider(session: object | None, module_id: str): ) +def _configuration_provider(context: ModuleContext) -> object: + del context + from govoplan_mail.backend.configuration_provider import SqlMailConfigurationProvider + + return SqlMailConfigurationProvider() + + def _permission(scope: str, label: str, description: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( @@ -432,6 +440,7 @@ manifest = ModuleManifest( ), ), capability_factories={ + MAIL_CONFIGURATION_CAPABILITY: _configuration_provider, "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__( "govoplan_mail.backend.capabilities", @@ -447,6 +456,57 @@ manifest = ModuleManifest( ).SqlMailBounceProcessingProvider(), }, documentation=( + DocumentationTopic( + id="mail.configuration-package.smtp-profile", + title="Provision SMTP profiles from deployment receipts", + summary="Review and apply an idempotent Mail profile without moving plaintext credentials through a package.", + body=( + "The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. " + "Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. " + "Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, " + "and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test." + ), + layer="configured", + documentation_types=("admin",), + audience=("mail_admin", "administrator", "operator"), + related_modules=("core", "ops", "access"), + conditions=( + DocumentationCondition( + required_modules=("mail", "access"), + any_scopes=("admin:settings:read", "system:settings:read"), + ), + ), + links=( + DocumentationLink( + label="Configuration packages", + href="/admin?section=configuration-packages", + kind="runtime", + ), + DocumentationLink( + label="Mail handbook", + href="govoplan-mail/docs/MAIL_HANDBOOK.md", + kind="repository", + ), + ), + translations={ + "de": { + "title": "SMTP-Profile aus Bereitstellungsnachweisen einrichten", + "summary": "Ein idempotentes Mail-Profil prüfen und anwenden, ohne Klartext-Zugangsdaten durch ein Konfigurationspaket zu übertragen.", + "body": ( + "Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. " + "Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. " + "Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, " + "und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet." + ), + } + }, + metadata={ + "kind": "workflow", + "route": "/admin?section=configuration-packages", + "help_contexts": ["admin.configuration-packages", "mail.admin.profiles"], + }, + order=4, + ), DocumentationTopic( id="mail.quick-access-and-product-area", title="Mail in Communication and Messages", diff --git a/tests/test_configuration_provider.py b/tests/test_configuration_provider.py new file mode 100644 index 0000000..b5df4e9 --- /dev/null +++ b/tests/test_configuration_provider.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +from pathlib import Path +from dataclasses import replace +import tempfile +import unittest + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_access.backend.db import models as access_models # noqa: F401 +from govoplan_core.core.configuration_packages import ( + ConfigurationPackageFragment, + ConfigurationPreflightContext, +) +from govoplan_core.admin.models import SystemSettings +from govoplan_core.core.infrastructure_capabilities import ( + infrastructure_capability_receipt_from_mapping, +) +from govoplan_core.db.base import Base +from govoplan_core.db.session import configure_database, reset_database +from govoplan_core.security.credential_envelopes import CredentialEnvelope +from govoplan_mail.backend.configuration_provider import ( + MAIL_CONFIGURATION_CAPABILITY, + SqlMailConfigurationProvider, +) +from govoplan_mail.backend.db.models import ( + MailProfilePolicy, + MailServerCredentialBinding, + MailServerEndpoint, + MailServerProfile, +) +from govoplan_mail.backend.manifest import manifest + + +def _receipt(): + return infrastructure_capability_receipt_from_mapping( + { + "schema_version": 1, + "installation_id": "mail-provider-test", + "profile": "evaluation", + "capabilities": [ + { + "id": "mail.smtp", + "label": "SMTP delivery", + "state": "available_unconfigured", + "source": "installer-managed-test", + "detail": "GreenMail is available for profile binding.", + "endpoint": { + "scheme": "smtp", + "host": "test-mail", + "port": 3025, + }, + "secret_refs": [], + "dependent_modules": ["mail"], + } + ], + "post_install_tasks": [ + { + "id": "mail.smtp-profile", + "resume_key": "mail-provider-test:mail.smtp-profile:v1", + "capability_id": "mail.smtp", + "state": "pending", + "owner_module": "mail", + "summary": "Create a Mail SMTP profile.", + "required_inputs": ["credential envelope reference when required"], + "secret_boundary": "credential-envelope-reference-only", + } + ], + } + ) + + +def _external_receipt(*, credential_required: bool = False): + return infrastructure_capability_receipt_from_mapping( + { + "schema_version": 1, + "installation_id": "mail-provider-external", + "profile": "production", + "capabilities": [ + { + "id": "mail.smtp", + "label": "SMTP delivery", + "state": "available_unconfigured", + "source": "operator-supplied", + "detail": "An external relay needs reviewed Mail configuration.", + "endpoint": {}, + "secret_refs": ( + ["env:SMTP_CREDENTIAL_ENVELOPE_REF"] + if credential_required + else [] + ), + "dependent_modules": ["mail"], + } + ], + "post_install_tasks": [], + } + ) + + +class MailConfigurationProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory(prefix="govoplan-mail-config-") + self.addCleanup(self.tempdir.cleanup) + database_path = Path(self.tempdir.name) / "mail.sqlite3" + self.engine = create_engine(f"sqlite:///{database_path}") + Base.metadata.create_all( + self.engine, + tables=( + access_models.User.__table__, + SystemSettings.__table__, + CredentialEnvelope.__table__, + MailServerProfile.__table__, + MailServerEndpoint.__table__, + MailServerCredentialBinding.__table__, + MailProfilePolicy.__table__, + ), + ) + configure_database( + f"sqlite:///{database_path}", + engine=self.engine, + dispose_previous=True, + ) + self.SessionLocal = sessionmaker( + bind=self.engine, + class_=Session, + expire_on_commit=False, + ) + with self.SessionLocal() as session: + session.add( + MailProfilePolicy( + id="tenant-policy", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + policy={}, + ) + ) + session.add( + CredentialEnvelope( + id="credential-1", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="SMTP credential", + credential_kind="username_password", + public_data={"username": "mailer"}, + secret_data_encrypted="encrypted-outside-package", + secret_keys=["password"], + allowed_modules=["mail"], + allowed_server_refs=[], + inherit_to_lower_scopes=True, + is_active=True, + ) + ) + session.commit() + self.addCleanup(self._cleanup_database) + self.provider = SqlMailConfigurationProvider() + self.context = ConfigurationPreflightContext( + tenant_id="tenant-1", + operator_user_id=None, + infrastructure_receipt=_receipt(), + ) + + def _cleanup_database(self) -> None: + reset_database() + self.engine.dispose() + + def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None: + self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories) + description = self.provider.describe() + self.assertEqual(("smtp_profile",), description.fragment_types) + + def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="test-smtp", + payload={"credential_envelope_id": "credential-1"}, + ) + + first_plan = self.provider.preflight(fragment, self.context) + self.assertEqual("create", first_plan.plan[0].action) + self.assertFalse( + any(item.severity == "blocker" for item in first_plan.diagnostics) + ) + first_apply = self.provider.apply(fragment, {}, self.context) + self.assertIn("test-smtp", first_apply.created_refs) + + second_plan = self.provider.preflight(fragment, self.context) + second_apply = self.provider.apply(fragment, {}, self.context) + + self.assertEqual("skip", second_plan.plan[0].action) + self.assertEqual({}, second_apply.created_refs) + self.assertEqual({}, second_apply.updated_refs) + with self.SessionLocal() as session: + profiles = session.scalars(select(MailServerProfile)).all() + servers = session.scalars(select(MailServerEndpoint)).all() + bindings = session.scalars(select(MailServerCredentialBinding)).all() + self.assertEqual(1, len(profiles)) + self.assertEqual("test-mail", profiles[0].smtp_config["host"]) + self.assertEqual("plain", profiles[0].smtp_config["security"]) + self.assertEqual(1, len(servers)) + self.assertEqual("test-mail", servers[0].config["host"]) + self.assertEqual(1, len(bindings)) + self.assertEqual("credential-1", bindings[0].credential_id) + + def test_conflicting_existing_profile_is_preserved_by_default(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="test-smtp", + payload={}, + ) + self.provider.apply(fragment, {}, self.context) + with self.SessionLocal() as session: + server = session.scalar(select(MailServerEndpoint)) + assert server is not None + server.config = {**server.config, "host": "manually-changed.example.test"} + session.commit() + + plan = self.provider.preflight(fragment, self.context) + result = self.provider.apply(fragment, {}, self.context) + + self.assertEqual("blocked", plan.plan[0].action) + self.assertIn( + "mail_configuration_conflict", + {item.code for item in result.diagnostics}, + ) + with self.SessionLocal() as session: + server = session.scalar(select(MailServerEndpoint)) + assert server is not None + self.assertEqual("manually-changed.example.test", server.config["host"]) + + def test_explicit_conflict_update_reconciles_then_becomes_noop(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="test-smtp", + payload={}, + ) + self.provider.apply(fragment, {}, self.context) + with self.SessionLocal() as session: + server = session.scalar(select(MailServerEndpoint)) + assert server is not None + server.config = {**server.config, "host": "manually-changed.example.test"} + session.commit() + update_fragment = replace( + fragment, + payload={"on_conflict": "update"}, + ) + + plan = self.provider.preflight(update_fragment, self.context) + result = self.provider.apply(update_fragment, {}, self.context) + settled = self.provider.preflight(update_fragment, self.context) + + self.assertEqual("update", plan.plan[0].action) + self.assertIn("test-smtp", result.updated_refs) + self.assertEqual("skip", settled.plan[0].action) + with self.SessionLocal() as session: + server = session.scalar(select(MailServerEndpoint)) + assert server is not None + self.assertEqual("test-mail", server.config["host"]) + + def test_inline_credentials_are_rejected(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + payload={ + "smtp": { + "host": "test-mail", + "port": 3025, + "security": "plain", + "password": "must-not-cross-boundary", + } + }, + ) + + plan = self.provider.preflight(fragment, self.context) + + self.assertEqual("blocked", plan.plan[0].action) + self.assertIn( + "mail_configuration_secret_forbidden", + {item.code for item in plan.diagnostics}, + ) + + def test_external_relay_collects_missing_non_secret_inputs(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="external-smtp", + payload={}, + ) + missing = self.provider.preflight( + fragment, + replace(self.context, infrastructure_receipt=_external_receipt()), + ) + ready = self.provider.preflight( + fragment, + replace( + self.context, + infrastructure_receipt=_external_receipt(), + supplied_data={ + "mail.smtp.external-smtp.host": "smtp.example.test", + "mail.smtp.external-smtp.port": 587, + "mail.smtp.external-smtp.security": "starttls", + }, + ), + ) + + self.assertEqual("blocked", missing.plan[0].action) + self.assertEqual( + { + "mail.smtp.external-smtp.host", + "mail.smtp.external-smtp.port", + "mail.smtp.external-smtp.security", + }, + {item.key for item in missing.required_data if item.required}, + ) + self.assertEqual("create", ready.plan[0].action) + + def test_receipt_secret_reference_requires_credential_envelope_reference( + self, + ) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="authenticated-smtp", + payload={ + "smtp": { + "host": "smtp.example.test", + "port": 587, + "security": "starttls", + } + }, + ) + context = replace( + self.context, + infrastructure_receipt=_external_receipt(credential_required=True), + ) + + missing = self.provider.preflight(fragment, context) + ready = self.provider.preflight( + ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="authenticated-smtp", + payload={ + **fragment.payload, + "credential_envelope_id": "credential-1", + }, + ), + context, + ) + + self.assertEqual("blocked", missing.plan[0].action) + self.assertIn( + "mail.smtp.authenticated-smtp.credential_envelope_id", + {item.key for item in missing.required_data if item.required}, + ) + self.assertEqual("create", ready.plan[0].action) + + def test_system_profile_requires_system_configuration_authority(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + payload={"profile": {"scope_type": "system"}}, + ) + + blocked = self.provider.preflight(fragment, self.context) + ready = self.provider.preflight( + fragment, + replace( + self.context, + operator_scopes=frozenset({"system:settings:write"}), + ), + ) + + self.assertEqual("blocked", blocked.plan[0].action) + self.assertIn( + "system_configuration_authority_required", + {item.code for item in blocked.diagnostics}, + ) + self.assertEqual("create", ready.plan[0].action) + + +if __name__ == "__main__": + unittest.main()