1 Commits
Author SHA1 Message Date
zemion 7c689b6939 feat(dms): add dvelop d3 integration profile
Module Package Release / publish-packages (push) Successful in 10s
2026-08-23 10:58:06 +02:00
10 changed files with 1003 additions and 0 deletions
+270
View File
@@ -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
+4
View File
@@ -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).
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
+30
View File
@@ -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.
+32
View File
@@ -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"
+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 @@
+121
View File
@@ -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]