feat: inventory SMTP infrastructure dependencies
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-24 14:56:27 +02:00
parent ecc1283de7
commit c62c7783d6
9 changed files with 139 additions and 14 deletions
@@ -5,7 +5,7 @@ from dataclasses import dataclass, replace
import re
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from govoplan_core.core.configuration_packages import (
@@ -24,6 +24,8 @@ from govoplan_core.core.configuration_packages import (
from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapability,
InfrastructureCapabilityReceipt,
InfrastructureDependency,
InfrastructureDependencyProvider,
)
from govoplan_core.security.credential_envelopes import (
CredentialAccessContext,
@@ -51,6 +53,9 @@ from govoplan_mail.backend.server_hierarchy import (
MAIL_CONFIGURATION_CAPABILITY = "mail.configuration"
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
"infrastructure.dependency_inventory.mail"
)
SMTP_PROFILE_FRAGMENT = "smtp_profile"
_SYSTEM_CONFIGURATION_SCOPES = frozenset(
{"system:settings:write", "system:governance:write"}
@@ -132,8 +137,12 @@ class _ProfileTarget:
on_conflict: str
class SqlMailConfigurationProvider(ConfigurationProvider):
class SqlMailConfigurationProvider(
ConfigurationProvider,
InfrastructureDependencyProvider,
):
module_id = "mail"
capability_ids = ("mail.smtp",)
def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription(
@@ -204,6 +213,84 @@ class SqlMailConfigurationProvider(ConfigurationProvider):
item for item in import_result.diagnostics if item.severity == "blocker"
)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
with get_database().session() as session:
return _smtp_infrastructure_dependencies(session)
def _smtp_infrastructure_dependencies(
session: Session,
) -> tuple[InfrastructureDependency, ...]:
endpoints = tuple(
session.execute(
select(MailServerEndpoint)
.where(MailServerEndpoint.protocol == "smtp")
.order_by(MailServerEndpoint.id)
).scalars()
)
binding_counts = {
str(server_id): int(count)
for server_id, count in session.execute(
select(
MailServerCredentialBinding.server_id,
func.count(MailServerCredentialBinding.id),
).group_by(MailServerCredentialBinding.server_id)
)
}
dependencies: list[InfrastructureDependency] = []
endpoint_profile_ids: set[str] = set()
for endpoint in endpoints:
endpoint_profile_ids.add(endpoint.profile_id)
dependencies.append(
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="smtp_endpoint",
dependency_ref=mail_server_ref(endpoint.id) or f"mail:{endpoint.id}",
state="active" if endpoint.is_active else "inactive",
scope=str(endpoint.scope_type or "tenant"),
summary=(
"A persisted Mail SMTP endpoint is bound to the deployment relay."
),
metrics={
"credential_binding_count": binding_counts.get(endpoint.id, 0),
"default_endpoint": int(bool(endpoint.is_default)),
},
required_action=(
"Rebind, migrate, or explicitly retire this SMTP endpoint and its credential-envelope references before changing the relay capability."
),
)
)
legacy_profiles = tuple(
session.execute(
select(MailServerProfile)
.where(MailServerProfile.smtp_config.is_not(None))
.order_by(MailServerProfile.id)
).scalars()
)
for profile in legacy_profiles:
if profile.id in endpoint_profile_ids or not dict(profile.smtp_config or {}):
continue
dependencies.append(
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="legacy_smtp_profile",
dependency_ref=f"mail-profile:{profile.id}",
state="active" if profile.is_active else "inactive",
scope=str(profile.scope_type or "tenant"),
summary=(
"A persisted legacy Mail profile still contains SMTP transport configuration."
),
metrics={"credential_binding_count": 0},
required_action=(
"Migrate or explicitly retire this legacy profile before changing the relay capability."
),
)
)
return tuple(dependencies)
def _preflight_smtp_profile(
session: Session,
+12 -4
View File
@@ -48,7 +48,10 @@ 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.configuration_provider import (
MAIL_CONFIGURATION_CAPABILITY,
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
)
from govoplan_mail.backend.documentation import (
documentation_configuration_states,
documentation_topics,
@@ -447,7 +450,7 @@ POP3_PROVIDER = ExternalProviderDeclaration(
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.25",
version="0.1.26",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
provides_interfaces=(
@@ -642,6 +645,7 @@ manifest = ModuleManifest(
),
capability_factories={
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider,
MAIL_INFRASTRUCTURE_DEPENDENCY_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",
@@ -878,7 +882,9 @@ manifest = ModuleManifest(
"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."
"and an unchanged second apply is a no-op. Before the host deployer changes or removes mail.smtp, Mail inventories every persisted SMTP endpoint, "
"legacy SMTP profile, and credential-binding count through the non-secret Ops dependency report. Missing, stale, or incomplete inventory blocks apply; "
"SMTP reachability remains a separate Mail profile test."
),
layer="configured",
documentation_types=("admin",),
@@ -910,7 +916,9 @@ manifest = ModuleManifest(
"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."
"und eine unveränderte zweite Anwendung bleibt wirkungslos. Bevor der Host-Deployer mail.smtp ändert oder entfernt, inventarisiert Mail alle gespeicherten SMTP-Endpunkte, "
"älteren SMTP-Profile und die Anzahl ihrer Zugangsdatenbindungen im nicht geheimen Ops-Abhängigkeitsbericht. Ein fehlendes, veraltetes oder unvollständiges Inventar blockiert die Anwendung; "
"die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet."
),
}
},