feat: provision SMTP profiles from deployment receipts
This commit is contained in:
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user