From 7c689b69399096ef95bd3fac0cbf8b0c47ee915d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 23 Aug 2026 10:58:06 +0200 Subject: [PATCH] feat(dms): add dvelop d3 integration profile --- .gitea/workflows/module-package-release.yml | 270 ++++++++++++++++++ README.md | 4 + docs/DVELOP_D3_INTEGRATION.md | 30 ++ pyproject.toml | 32 +++ src/govoplan_dms/__init__.py | 4 + src/govoplan_dms/backend/__init__.py | 2 + src/govoplan_dms/backend/dvelop_d3.py | 292 ++++++++++++++++++++ src/govoplan_dms/backend/manifest.py | 247 +++++++++++++++++ src/govoplan_dms/py.typed | 1 + tests/test_dvelop_d3.py | 121 ++++++++ 10 files changed, 1003 insertions(+) create mode 100644 .gitea/workflows/module-package-release.yml create mode 100644 docs/DVELOP_D3_INTEGRATION.md create mode 100644 pyproject.toml create mode 100644 src/govoplan_dms/__init__.py create mode 100644 src/govoplan_dms/backend/__init__.py create mode 100644 src/govoplan_dms/backend/dvelop_d3.py create mode 100644 src/govoplan_dms/backend/manifest.py create mode 100644 src/govoplan_dms/py.typed create mode 100644 tests/test_dvelop_d3.py diff --git a/.gitea/workflows/module-package-release.yml b/.gitea/workflows/module-package-release.yml new file mode 100644 index 0000000..ef7ae89 --- /dev/null +++ b/.gitea/workflows/module-package-release.yml @@ -0,0 +1,270 @@ +name: Module Package Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_tag: + description: Existing protected version tag to publish + required: true + type: string + +jobs: + publish-packages: + runs-on: ubuntu-latest + env: + GITEA_REPOSITORY: ${{ gitea.repository }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Select and validate protected release tag + shell: bash + env: + REQUESTED_TAG: ${{ inputs.release_tag }} + TRIGGER_TAG: ${{ gitea.ref_name }} + run: | + set -euo pipefail + tag="${REQUESTED_TAG:-$TRIGGER_TAG}" + case "$tag" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;; + esac + git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main + tag_commit="$(git rev-list -n 1 "$tag")" + git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || { + echo "Release tag is not contained in main" >&2 + exit 1 + } + git checkout --detach "$tag" + printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV" + printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV" + - name: Validate package versions + run: | + python - <<'PY' + import json + from pathlib import Path + import os + import re + import tomllib + + tag = os.environ["RELEASE_TAG"] + expected = tag.removeprefix("v") + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + if project.get("version") != expected: + raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}") + if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None: + raise SystemExit("Python distribution name must use the govoplan-* namespace") + webui = Path("webui/package.json") + if webui.is_file(): + package = json.loads(webui.read_text(encoding="utf-8")) + if package.get("version") != expected: + raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}") + if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None: + raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace") + release = Path("webui/package.release.json") + if release.is_file(): + release_package = json.loads(release.read_text(encoding="utf-8")) + if ( + release_package.get("name") != package.get("name") + or release_package.get("version") != expected + ): + raise SystemExit("WebUI release package identity does not match package.json and the release tag") + PY + - name: Build immutable package artifacts + shell: bash + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 + rm -rf dist .package-webui + python -m build --wheel --outdir dist + python -m twine check dist/*.whl + if [[ -f webui/package.json ]]; then + mkdir .package-webui + cp -a webui/. .package-webui/ + rm -rf .package-webui/node_modules .package-webui/dist + if [[ -f .package-webui/package.release.json ]]; then + cp .package-webui/package.release.json .package-webui/package.json + fi + node <<'NODE' + const fs = require("node:fs"); + const path = ".package-webui/package.json"; + const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); + const groups = ["dependencies", "optionalDependencies", "peerDependencies"]; + for (const group of groups) { + for (const [name, specifier] of Object.entries(packageJson[group] || {})) { + if (!name.startsWith("@govoplan/")) continue; + if (typeof specifier !== "string") { + throw new Error(`${group}.${name} must use a string version`); + } + const packageSlug = name.slice("@govoplan/".length); + if (!packageSlug.endsWith("-webui")) { + throw new Error(`${group}.${name} is outside the WebUI package namespace`); + } + const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`; + const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const gitTag = specifier.match( + new RegExp( + `^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`, + ), + ); + if (gitTag) { + packageJson[group][name] = gitTag[1]; + continue; + } + if (specifier.startsWith("file:") || specifier.startsWith("git+")) { + throw new Error( + `${group}.${name} must resolve to an exact registry version for publication`, + ); + } + } + } + delete packageJson.private; + fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + npm pkg delete private --prefix .package-webui + (cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist) + fi + python - <<'PY' + import hashlib + import json + from pathlib import Path + import os + import subprocess + + artifacts = [] + for path in sorted(Path("dist").iterdir()): + if path.suffix not in {".whl", ".tgz"}: + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size}) + payload = { + "schema_version": "1", + "repository": os.environ["GITEA_REPOSITORY"], + "tag": os.environ["RELEASE_TAG"], + "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "artifacts": artifacts, + } + Path("dist/package-artifacts.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Retain package hash evidence + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 + with: + name: module-packages-${{ gitea.ref_name }} + path: dist/package-artifacts.json + - name: Check immutable registry state + shell: bash + env: + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_TOKEN" + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import tomllib + from urllib.error import HTTPError + from urllib.parse import quote + from urllib.request import Request, urlopen + + api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN" + token = os.environ["PACKAGE_TOKEN"] + + def should_publish(kind, name, version, path): + package_url = "/".join( + (api_root, kind, quote(name, safe=""), quote(version, safe=""), "files") + ) + request = Request( + package_url, + headers={"Accept": "application/json", "Authorization": f"token {token}"}, + ) + try: + with urlopen(request, timeout=30) as response: + files = json.load(response) + except HTTPError as exc: + if exc.code == 404: + print(f"{kind} package {name}=={version} is not published yet") + return True + raise + if not isinstance(files, list) or len(files) != 1: + raise SystemExit( + f"immutable {kind} package {name}=={version} has an unexpected file set" + ) + expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + if files[0].get("sha256") != expected_sha256: + raise SystemExit( + f"immutable {kind} package {name}=={version} already exists with a different SHA-256" + ) + print(f"verified existing {kind} package {name}=={version} ({expected_sha256})") + return False + + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + wheels = tuple(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("release build must contain exactly one wheel") + publish_pypi = should_publish( + "pypi", str(project["name"]), str(project["version"]), wheels[0] + ) + + tarballs = tuple(Path("dist").glob("*.tgz")) + if len(tarballs) > 1: + raise SystemExit("release build must contain at most one npm package") + publish_npm = False + if tarballs: + webui = json.loads( + Path(".package-webui/package.json").read_text(encoding="utf-8") + ) + publish_npm = should_publish( + "npm", str(webui["name"]), str(webui["version"]), tarballs[0] + ) + + with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n") + env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n") + PY + - name: Publish wheel and WebUI package + shell: bash + env: + PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" + test -n "$PACKAGE_TOKEN" + if [[ "$PUBLISH_PYPI" == 1 ]]; then + TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ + python -m twine upload --non-interactive \ + --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ + dist/*.whl + else + echo "Exact wheel is already present; skipping immutable retry." + fi + shopt -s nullglob + webui_packages=(dist/*.tgz) + if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then + npmrc="$(mktemp)" + trap 'rm -f "$npmrc"' EXIT + chmod 600 "$npmrc" + printf '%s\n' \ + '@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \ + "//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \ + > "$npmrc" + NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \ + --ignore-scripts --access public \ + --registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/ + elif (( ${#webui_packages[@]} )); then + echo "Exact WebUI package is already present; skipping immutable retry." + fi diff --git a/README.md b/README.md index 644b7d2..a4745aa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # GovOPlaN DMS +Optional integration module for external document-management systems. The first concrete product profile targets d.velop d3 DMSApp and provides repository discovery plus digest-bound request planning. Real target writes remain disabled until a configured d3 tenant passes mapping, custody, reconciliation, and recovery tests. + +See [the DMS boundary](docs/DMS_BOUNDARY.md) and [the d.velop d3 integration profile](docs/DVELOP_D3_INTEGRATION.md). + **Repository type:** module (domain). diff --git a/docs/DVELOP_D3_INTEGRATION.md b/docs/DVELOP_D3_INTEGRATION.md new file mode 100644 index 0000000..f7b9060 --- /dev/null +++ b/docs/DVELOP_D3_INTEGRATION.md @@ -0,0 +1,30 @@ +# d.velop d3 integration profile + +GovOPlaN's first concrete DMS product profile targets the d.velop d3 DMSApp HTTP interface. The slice deliberately separates discovery and request planning from effects. + +## Configuration boundary + +A tenant binding contains the HTTPS API base URL, repository ID, calling `Origin`, d3 source category, stable source ID, mapping revision, and an Access credential-envelope reference. The bearer token is resolved only for an outbound call and is never persisted in the DMS binding, plan, evidence, or diagnostics. + +The source category and source properties must be mapped by the d3 tenant administrator. GovOPlaN does not infer a document type from a repository response or silently adapt when that mapping changes. + +## Preflight and plan + +Preflight reads the DMSApp repository catalog, the selected repository, and its object definitions. It records observation time and canonical response digests. A successful preflight means that discovery and authentication worked; it does not prove that a source mapping accepts GovOPlaN data. + +The store-plan builder produces the documented `o2m` request shape with stable GovOPlaN package, record, revision, manifest digest, purpose, and mapping-revision properties. It never sends the request. The plan digest is the review boundary for a later target-tested write implementation. + +## Enablement gates + +Real Records dispatch remains fail-closed until target evidence demonstrates all of the following: + +- repository and object-definition discovery through the deployed gateway; +- administrator-reviewed source-property mapping; +- successful storage and correlation lookup by package ID and manifest digest; +- an unambiguous custody receipt or an explicit statement that d3 is only a DMS copy target; +- timeout and unknown-outcome reconciliation before retry; +- correction, quarantine, and recovery exercises; and +- credential rotation without exposing bearer values. + +Until those gates are met, the `records.archive.dvelop_d3` capability reports unhealthy and rejects dispatch. Records retains its approved, digest-bound package and does not claim external custody. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ae2d98d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-dms" +version = "0.1.19" +description = "GovOPlaN document-management integration module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.37", + "govoplan-access>=0.1.18", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_dms = ["py.typed"] + +[project.entry-points."govoplan.modules"] +dms = "govoplan_dms.backend.manifest:get_manifest" + +[tool.pytest.ini_options] +pythonpath = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + diff --git a/src/govoplan_dms/__init__.py b/src/govoplan_dms/__init__.py new file mode 100644 index 0000000..aab1c3e --- /dev/null +++ b/src/govoplan_dms/__init__.py @@ -0,0 +1,4 @@ +"""GovOPlaN DMS integration module.""" + +__version__ = "0.1.19" + diff --git a/src/govoplan_dms/backend/__init__.py b/src/govoplan_dms/backend/__init__.py new file mode 100644 index 0000000..d2c9c0a --- /dev/null +++ b/src/govoplan_dms/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration profiles for document-management systems.""" + diff --git a/src/govoplan_dms/backend/dvelop_d3.py b/src/govoplan_dms/backend/dvelop_d3.py new file mode 100644 index 0000000..2d8fc8e --- /dev/null +++ b/src/govoplan_dms/backend/dvelop_d3.py @@ -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", +] diff --git a/src/govoplan_dms/backend/manifest.py b/src/govoplan_dms/backend/manifest.py new file mode 100644 index 0000000..3810b8c --- /dev/null +++ b/src/govoplan_dms/backend/manifest.py @@ -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 diff --git a/src/govoplan_dms/py.typed b/src/govoplan_dms/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_dms/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_dvelop_d3.py b/tests/test_dvelop_d3.py new file mode 100644 index 0000000..5722464 --- /dev/null +++ b/tests/test_dvelop_d3.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import hashlib +import json + +import pytest + +from govoplan_core.core.records import ( + RecordArchiveTransferRequest, + RecordTransferPackage, +) +from govoplan_core.security.http_fetch import HttpFetchResponse +from govoplan_dms.backend.dvelop_d3 import ( + DVELOP_D3_ARCHIVE_PROFILE, + DvelopD3Client, + DvelopD3Profile, + UnconfiguredDvelopD3ArchiveProvider, +) +from govoplan_dms.backend.manifest import get_manifest + + +def _profile() -> DvelopD3Profile: + return DvelopD3Profile( + api_base_url="https://d3.example.test/api", + repository_id="repo-1", + origin="https://govoplan.example.test", + source_category="govoplan-record", + source_id="tenant-a", + mapping_revision="mapping-7", + credential_ref="core-credential:d3-tenant-a", + ) + + +def test_preflight_is_digest_bound_and_does_not_claim_dispatch_readiness() -> None: + calls: list[tuple[str, MappingLike]] = [] + + def transport(url, *, method, headers, body): + calls.append((url, dict(headers))) + assert method == "GET" + assert body is None + return HttpFetchResponse( + status=200, + headers={"Content-Type": "application/hal+json"}, + body=json.dumps({"_links": {"self": {"href": url}}}).encode(), + ) + + result = DvelopD3Client(_profile(), transport=transport).preflight() + + assert result.ready_for_mapping_test is True + assert result.dispatch_ready is False + assert len(calls) == 3 + assert all(headers["Origin"] == "https://govoplan.example.test" for _, headers in calls) + assert all("Authorization" not in headers for _, headers in calls) + + +def test_store_plan_binds_exact_record_package_without_effect() -> None: + package = RecordTransferPackage( + tenant_id="tenant-a", + package_id="package-1", + record_id="record-1", + record_revision=3, + profile=DVELOP_D3_ARCHIVE_PROFILE, + manifest_sha256="a" * 64, + manifest={"version": 1}, + ) + request = RecordArchiveTransferRequest( + package=package, + purpose="approved transfer", + idempotency_key="transfer-1", + ) + + plan = DvelopD3Client(_profile(), transport=lambda *args, **kwargs: None).build_record_store_plan( + request, + content_location_uri="https://files.example.test/content/package-1", + ) + + assert plan.body["sourceProperties"]["govoplanManifestSha256"] == "a" * 64 + assert plan.body["sourceProperties"]["govoplanMappingRevision"] == "mapping-7" + canonical = json.dumps(plan.body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + assert plan.body_sha256 == hashlib.sha256(canonical).hexdigest() + + +def test_unconfigured_provider_fails_closed() -> None: + provider = UnconfiguredDvelopD3ArchiveProvider() + assert provider.state().healthy is False + with pytest.raises(Exception, match="dispatch is disabled"): + provider.dispatch(None, None, request=None) # type: ignore[arg-type] + + +def test_profile_rejects_origin_paths_and_embedded_credentials() -> None: + with pytest.raises(ValueError, match="Origin"): + DvelopD3Profile( + api_base_url="https://d3.example.test/api", + repository_id="repo", + origin="https://govoplan.example.test/path", + source_category="category", + source_id="source", + mapping_revision="one", + credential_ref="credential", + ) + with pytest.raises(ValueError, match="credentials"): + DvelopD3Profile( + api_base_url="https://user:secret@d3.example.test/api", + repository_id="repo", + origin="https://govoplan.example.test", + source_category="category", + source_id="source", + mapping_revision="one", + credential_ref="credential", + ) + + +def test_manifest_declares_d3_without_claiming_target_test() -> None: + manifest = get_manifest() + assert manifest.version == "0.1.19" + assert manifest.external_providers[0].id == "dms.dvelop_d3" + assert manifest.architecture is not None + assert manifest.architecture.target_tested_providers == () + + +MappingLike = dict[str, str]