feat(dms): add dvelop d3 integration profile
Module Package Release / publish-packages (push) Successful in 10s

This commit is contained in:
2026-08-23 10:58:06 +02:00
parent 76c2fbd654
commit 7c689b6939
10 changed files with 1003 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""GovOPlaN DMS integration module."""
__version__ = "0.1.19"
+2
View File
@@ -0,0 +1,2 @@
"""Backend integration profiles for document-management systems."""
+292
View File
@@ -0,0 +1,292 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
import hashlib
import json
from typing import Protocol
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
from govoplan_core.core.records import (
RecordArchiveProviderState,
RecordArchiveReceipt,
RecordArchiveTransferRequest,
RecordContractError,
)
from govoplan_core.security.http_fetch import HttpFetchResponse, fetch_http, validate_http_url
DVELOP_D3_PROVIDER_ID = "dvelop_d3"
DVELOP_D3_ARCHIVE_PROFILE = "dvelop-d3-record-transfer-v1"
DVELOP_D3_EXTERNAL_PROVIDER_ID = "dms.dvelop_d3"
MAX_D3_RESPONSE_BYTES = 4 * 1024 * 1024
class DvelopD3Error(RuntimeError):
"""Stable, sanitized d.velop d3 integration error."""
class DvelopD3Transport(Protocol):
def __call__(
self,
url: str,
*,
method: str,
headers: Mapping[str, str],
body: bytes | None,
) -> HttpFetchResponse: ...
@dataclass(frozen=True, slots=True)
class DvelopD3Profile:
"""Non-secret tenant binding for one d.velop d3 DMSApp repository."""
api_base_url: str
repository_id: str
origin: str
source_category: str
source_id: str
mapping_revision: str
credential_ref: str
def __post_init__(self) -> None:
normalized_base = validate_http_url(self.api_base_url, label="d.velop d3 API base URL")
normalized_origin = validate_http_url(self.origin, label="d.velop d3 Origin")
origin_parts = urlsplit(normalized_origin)
if origin_parts.path not in {"", "/"} or origin_parts.query or origin_parts.fragment:
raise ValueError("d.velop d3 Origin must contain only scheme, host, and port.")
for name in (
"repository_id",
"source_category",
"source_id",
"mapping_revision",
"credential_ref",
):
value = str(getattr(self, name) or "").strip()
if not value or len(value) > 255:
raise ValueError(f"d.velop d3 {name.replace('_', ' ')} is required and limited to 255 characters.")
object.__setattr__(self, name, value)
object.__setattr__(self, "api_base_url", normalized_base.rstrip("/"))
object.__setattr__(
self,
"origin",
urlunsplit((origin_parts.scheme, origin_parts.netloc, "", "", "")),
)
@dataclass(frozen=True, slots=True)
class DvelopD3PreflightResult:
repository_id: str
observed_at: datetime
repository_catalog_sha256: str
repository_sha256: str
object_definitions_sha256: str
mapping_revision: str
ready_for_mapping_test: bool
dispatch_ready: bool = False
limitations: tuple[str, ...] = (
"Repository discovery does not prove a configured source mapping.",
"Archive custody and recovery conformance require separate target evidence.",
)
@dataclass(frozen=True, slots=True)
class DvelopD3StorePlan:
"""Reviewable DMSApp o2m request; executing it is a separate governed effect."""
repository_id: str
endpoint: str
origin: str
idempotency_key: str
body: Mapping[str, object]
body_sha256: str
mapping_revision: str
@dataclass(slots=True)
class DvelopD3Client:
profile: DvelopD3Profile
bearer_token: str | None = field(default=None, repr=False)
transport: DvelopD3Transport | None = field(default=None, repr=False)
timeout_seconds: int = 30
def preflight(self) -> DvelopD3PreflightResult:
catalog = self._get_json("dms/r")
repository = self._get_json(
f"dms/r/{quote(self.profile.repository_id, safe='')}"
)
object_definitions = self._get_json(
f"dms/r/{quote(self.profile.repository_id, safe='')}/objdef"
)
return DvelopD3PreflightResult(
repository_id=self.profile.repository_id,
observed_at=datetime.now(UTC),
repository_catalog_sha256=_json_sha256(catalog),
repository_sha256=_json_sha256(repository),
object_definitions_sha256=_json_sha256(object_definitions),
mapping_revision=self.profile.mapping_revision,
ready_for_mapping_test=True,
)
def build_record_store_plan(
self,
request: RecordArchiveTransferRequest,
*,
content_location_uri: str,
) -> DvelopD3StorePlan:
if request.package.profile != DVELOP_D3_ARCHIVE_PROFILE:
raise DvelopD3Error("The transfer package does not use the d.velop d3 profile.")
content_uri = validate_http_url(
content_location_uri,
label="d.velop d3 content location URI",
)
body: dict[str, object] = {
"sourceCategory": self.profile.source_category,
"sourceId": self.profile.source_id,
"sourceProperties": {
"govoplanPackageId": request.package.package_id,
"govoplanRecordId": request.package.record_id,
"govoplanRecordRevision": str(request.package.record_revision),
"govoplanManifestSha256": request.package.manifest_sha256,
"govoplanPurpose": request.purpose,
"govoplanMappingRevision": self.profile.mapping_revision,
},
"contentLocationUri": content_uri,
}
encoded = _canonical_json(body)
return DvelopD3StorePlan(
repository_id=self.profile.repository_id,
endpoint=self._url(
f"dms/r/{quote(self.profile.repository_id, safe='')}/o2m"
),
origin=self.profile.origin,
idempotency_key=request.idempotency_key,
body=body,
body_sha256=hashlib.sha256(encoded).hexdigest(),
mapping_revision=self.profile.mapping_revision,
)
def _get_json(self, relative_path: str) -> object:
response = self._request(relative_path, method="GET", body=None)
if response.status != 200:
raise DvelopD3Error(
f"d.velop d3 discovery returned HTTP {response.status}."
)
content_type = next(
(
value
for key, value in response.headers.items()
if key.casefold() == "content-type"
),
"",
).partition(";")[0].strip().casefold()
if content_type not in {"application/json", "application/hal+json"}:
raise DvelopD3Error("d.velop d3 discovery did not return JSON or HAL+JSON.")
if len(response.body) > MAX_D3_RESPONSE_BYTES:
raise DvelopD3Error("d.velop d3 discovery response exceeded the safety limit.")
try:
return json.loads(response.body)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DvelopD3Error("d.velop d3 discovery returned malformed JSON.") from exc
def _request(
self,
relative_path: str,
*,
method: str,
body: bytes | None,
) -> HttpFetchResponse:
url = self._url(relative_path)
headers = {
"Accept": "application/hal+json, application/json",
"Origin": self.profile.origin,
}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
if self.transport is not None:
return self.transport(url, method=method, headers=headers, body=body)
if not self.bearer_token:
raise DvelopD3Error(
"d.velop d3 authentication is unavailable; resolve the configured credential envelope first."
)
return fetch_http(
url,
method=method,
headers=headers,
body=body,
timeout=self.timeout_seconds,
max_bytes=MAX_D3_RESPONSE_BYTES,
label="d.velop d3 DMSApp",
redirect_sensitive_headers=("Authorization",),
)
def _url(self, relative_path: str) -> str:
url = urljoin(f"{self.profile.api_base_url}/", relative_path.lstrip("/"))
base = urlsplit(self.profile.api_base_url)
candidate = urlsplit(url)
if _origin(base) != _origin(candidate):
raise DvelopD3Error("d.velop d3 endpoint escaped the configured API origin.")
return url
class UnconfiguredDvelopD3ArchiveProvider:
"""Advertise the real-product profile without enabling an untested effect."""
provider_id = DVELOP_D3_PROVIDER_ID
def state(self) -> RecordArchiveProviderState:
return RecordArchiveProviderState(
provider_id=self.provider_id,
label="d.velop d3 DMS",
profiles=(DVELOP_D3_ARCHIVE_PROFILE,),
authority_modes=("external_authoritative",),
healthy=False,
checked_at=datetime.now(UTC),
limitations=(
"A tenant repository, credential envelope, source mapping, and target test are required.",
"Records dispatch remains disabled until custody and recovery are evidenced.",
),
simulated=False,
)
def dispatch(
self,
session: object,
principal: object,
*,
request: RecordArchiveTransferRequest,
) -> RecordArchiveReceipt:
del session, principal, request
raise RecordContractError(
"d.velop d3 dispatch is disabled until the configured repository passes mapping, custody, and recovery target tests."
)
def _origin(parts) -> tuple[str, str, int | None]:
return (
parts.scheme.casefold(),
(parts.hostname or "").casefold(),
parts.port or (443 if parts.scheme.casefold() == "https" else 80),
)
def _canonical_json(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _json_sha256(value: object) -> str:
return hashlib.sha256(_canonical_json(value)).hexdigest()
__all__ = [
"DVELOP_D3_ARCHIVE_PROFILE",
"DVELOP_D3_EXTERNAL_PROVIDER_ID",
"DVELOP_D3_PROVIDER_ID",
"DvelopD3Client",
"DvelopD3Error",
"DvelopD3PreflightResult",
"DvelopD3Profile",
"DvelopD3StorePlan",
"UnconfiguredDvelopD3ArchiveProvider",
]
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
from datetime import UTC, datetime
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
ModuleContext,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderRuntimeState,
ExternalProviderStateContext,
ExternalProviderStateProviderRegistration,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
declared_module_architecture,
)
from govoplan_core.core.records import record_archive_capability
from govoplan_dms.backend.dvelop_d3 import (
DVELOP_D3_EXTERNAL_PROVIDER_ID,
DVELOP_D3_PROVIDER_ID,
UnconfiguredDvelopD3ArchiveProvider,
)
MODULE_ID = "dms"
MODULE_VERSION = "0.1.19"
READ_SCOPE = "dms:integration:read"
ADMIN_SCOPE = "dms:integration:admin"
D3_ARCHIVE_CAPABILITY = record_archive_capability(DVELOP_D3_PROVIDER_ID)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="DMS",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
D3_PROVIDER = ExternalProviderDeclaration(
id=DVELOP_D3_EXTERNAL_PROVIDER_ID,
module_id=MODULE_ID,
label="d.velop d3 DMSApp repository",
maturity="read",
operations=("discover", "read", "preview", "dry_run"),
objects=(
ProviderObjectDeclaration(
object_type="repository",
field_groups=("identity", "object_definitions", "source_mapping"),
authority_modes=("external_authoritative", "linked_reference"),
default_authority_mode="external_authoritative",
),
ProviderObjectDeclaration(
object_type="record_transfer",
field_groups=("manifest", "content_location", "mapping_revision"),
authority_modes=("external_authoritative",),
default_authority_mode="external_authoritative",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Repository and object-definition digests plus the configured mapping revision are retained.",
concurrency="A reviewed plan is bound to the exact GovOPlaN manifest and mapping revision.",
freshness="Repository discovery records its observation time and response digests.",
health="Discovery, authentication, repository lookup, object definitions, mapping, custody, and recovery are distinct gates.",
max_read_items=1000,
idempotency="The Records package id and manifest digest form the target correlation key.",
retry="Discovery is retryable; writes remain disabled until target-specific reconciliation is proven.",
timeout_seconds=30,
conflicts="Mapping changes invalidate an earlier store plan and require a new review.",
outcome_unknown="A timed-out write must be reconciled by package correlation before retry.",
outcome_unknown_supported=True,
evidence="Endpoint, repository, mapping revision, response digests, package digest, and target receipts are evidence; secrets are excluded.",
correction="Correct repository or mapping configuration and produce a newly digest-bound plan.",
rollback="Repository effects are not assumed to be transactionally reversible.",
compensation="Target-specific recovery may supersede or quarantine an erroneous object after reconciliation.",
reconciliation="Look up the stable package correlation and compare the exact manifest digest before any retry.",
outage="Records retains its prepared package and never infers custody from an unavailable target.",
classifications=("confidential", "restricted"),
purposes=("document management", "governed record transfer"),
retention="Records and target DMS retention policies remain explicit and independently evidenced.",
secret_handling="Only credential-envelope references are configured; bearer values are never persisted or returned.",
),
capability_names=(D3_ARCHIVE_CAPABILITY,),
documentation_topic_ids=("dms.dvelop-d3",),
)
def _d3_archive_provider(context: ModuleContext) -> UnconfiguredDvelopD3ArchiveProvider:
del context
return UnconfiguredDvelopD3ArchiveProvider()
def _d3_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
del context
return (
ExternalProviderRuntimeState(
provider_id=DVELOP_D3_EXTERNAL_PROVIDER_ID,
observed_at=datetime.now(UTC),
configured=False,
active=False,
health="inactive",
freshness="not_applicable",
conflict="not_applicable",
recovery="unsupported",
detail="No target-tested d.velop d3 tenant binding is configured; dispatch is disabled.",
),
)
manifest = ModuleManifest(
id=MODULE_ID,
name="DMS",
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=("records", "files", "audit", "policy"),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
permissions=(
_permission(READ_SCOPE, "View DMS integration", "Inspect configured DMS bindings and non-secret target health."),
_permission(ADMIN_SCOPE, "Administer DMS integration", "Configure and test tenant DMS repositories, mappings, and recovery evidence."),
),
role_templates=(
RoleTemplate(
slug="dms_integration_administrator",
name="DMS integration administrator",
description="Configure and verify governed external document-management bindings.",
permissions=(READ_SCOPE, ADMIN_SCOPE),
),
),
capability_factories={D3_ARCHIVE_CAPABILITY: _d3_archive_provider},
external_providers=(D3_PROVIDER,),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id=MODULE_ID,
provider_id=DVELOP_D3_EXTERNAL_PROVIDER_ID,
provider=_d3_provider_states,
),
),
documentation=(
DocumentationTopic(
id="dms.boundary",
title="DMS integration boundary",
summary="Connect external document-management products without moving Files storage or Records lifecycle ownership into the connector.",
body=(
"DMS owns product-specific repository discovery, source mapping, version and reference semantics, and target receipts. Files continues to own binary storage, while Records owns eAkte filing, retention, holds, disposition, and archive handoff. A provider declaration is not proof that a configured target is healthy or conformant."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "records_manager"),
related_modules=("files", "records", "access"),
links=(DocumentationLink(label="DMS boundary", href="docs/DMS_BOUNDARY.md", kind="repository"),),
translations={
"de": {
"title": "Integrationsgrenze des DMS-Moduls",
"summary": "Externe Dokumentenmanagementprodukte anbinden, ohne Dateiablage oder Aktenlebenszyklus in den Konnektor zu verlagern.",
"body": "DMS verantwortet produktspezifische Repository-Erkennung, Quellzuordnung, Versions- und Referenzsemantik sowie Zielbelege. Files bleibt für Binärdaten zuständig; Records verwaltet Veraktung, Aufbewahrung, Sperren, Aussonderung und Archivübergabe. Eine Anbieterdeklaration beweist weder Gesundheit noch Konformität eines konfigurierten Ziels.",
}
},
order=100,
),
DocumentationTopic(
id="dms.dvelop-d3",
title="Configure and verify d.velop d3 DMSApp",
summary="Discover a tenant repository, bind an administrator-owned source mapping, and review a digest-bound transfer plan before effects are enabled.",
body=(
"Configure the HTTPS API base, repository id, calling Origin, d3 source category and source id, mapping revision, and a reusable Access credential envelope. Preflight reads the repository catalog, selected repository, and object definitions through DMSApp and retains only response digests. A store plan binds the exact Records package, content location, purpose, idempotency key, and mapping revision. Dispatch stays unavailable until a real target test proves authentication, source mapping, correlation lookup, custody receipt, unknown-outcome reconciliation, and recovery."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "records_manager", "auditor"),
related_modules=("records", "files", "access", "audit"),
conditions=(
DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),
),
links=(DocumentationLink(label="d.velop d3 integration profile", href="docs/DVELOP_D3_INTEGRATION.md", kind="repository"),),
translations={
"de": {
"title": "d.velop d3 DMSApp konfigurieren und prüfen",
"summary": "Ein Mandanten-Repository erkennen, eine administrativ verantwortete Quellzuordnung binden und vor Wirkungen einen prüfsummengebundenen Übergabeplan kontrollieren.",
"body": "Konfigurieren Sie HTTPS-API-Basis, Repository-ID, aufrufenden Origin, d3-Quellkategorie und -Quell-ID, Mapping-Revision sowie einen wiederverwendbaren Access-Berechtigungsnachweis. Der Vorabtest liest Repository-Katalog, ausgewähltes Repository und Objektdefinitionen über DMSApp und bewahrt nur Antwortprüfsummen. Ein Ablageplan bindet das exakte Records-Paket, die Inhaltsadresse, den Zweck, den Idempotenzschlüssel und die Mapping-Revision. Die Übergabe bleibt gesperrt, bis ein echter Zieltest Authentifizierung, Quellzuordnung, Korrelationssuche, Verwahrungsbeleg, Abgleich unbekannter Ergebnisse und Wiederherstellung nachweist.",
}
},
metadata={
"kind": "workflow",
"prerequisites": [
"A d.velop d3 tenant and DMSApp endpoint are available.",
"The tenant administrator has created and reviewed the source mapping.",
"Authentication is held in a scoped Access credential envelope.",
],
"steps": [
"Run repository and object-definition preflight.",
"Review the configured source category, source id, and mapping revision.",
"Build and compare the digest-bound store plan without executing it.",
"Complete target custody, reconciliation, and recovery evidence before enabling dispatch.",
],
"limitations": [
"This release does not enable real Records dispatch.",
"Repository discovery alone does not prove source mapping or archival custody.",
],
"consequences": [
"Changing the mapping revision invalidates an earlier plan.",
"A timeout never implies success and requires target reconciliation.",
"No secret is included in plans, diagnostics, or retained evidence.",
],
},
order=110,
),
),
architecture=declared_module_architecture(
layer="data_reporting_integration",
kind="integration",
maturity="vertical_slice",
documentation_ref="docs/DVELOP_D3_INTEGRATION.md",
test_ref="tests/test_dvelop_d3.py",
known_limits=("Real d.velop d3 writes require configured-target conformance and recovery evidence.",),
supported_authority_modes=("external_authoritative", "linked_reference"),
owned_concepts=("DMS product binding", "DMS source mapping", "DMS target receipt"),
non_owned_concepts=("file blob", "record lifecycle", "archive disposition"),
recovery_docs=("docs/DVELOP_D3_INTEGRATION.md",),
security_docs=("docs/DVELOP_D3_INTEGRATION.md",),
operations_docs=("docs/DVELOP_D3_INTEGRATION.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
+1
View File
@@ -0,0 +1 @@