feat(fit-connect): govern inbound acknowledgement plans
Module Package Release / publish-packages (push) Successful in 10s
Module Package Release / publish-packages (push) Successful in 10s
This commit is contained in:
@@ -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
|
||||||
@@ -3,3 +3,10 @@
|
|||||||
<!-- govoplan-repository-type:start -->
|
<!-- govoplan-repository-type:start -->
|
||||||
**Repository type:** connector (standard).
|
**Repository type:** connector (standard).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
Provider-neutral, effect-free contracts for FIT-Connect inbound submission
|
||||||
|
receipts and technical accept, reject, or defer acknowledgement plans. No
|
||||||
|
journey, environment, destination, API version, or credential is activated by
|
||||||
|
default.
|
||||||
|
|
||||||
|
See [the inbound receipt and acknowledgement contract](docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md).
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# FIT-Connect inbound receipt and acknowledgement
|
||||||
|
|
||||||
|
This slice models the receiving system's safe boundary around a FIT-Connect
|
||||||
|
submission. It does not choose a journey, environment, destination, API
|
||||||
|
profile, or credential, and it does not call FIT-Connect.
|
||||||
|
|
||||||
|
FIT-Connect distinguishes its technical receipt confirmation from subsequent
|
||||||
|
business review. An `accept-submission` event confirms that the receiver could
|
||||||
|
download, decrypt, and validate the submission. It must carry the matching
|
||||||
|
authentication tags. Successful acknowledgement can transition and remove the
|
||||||
|
service-side submission, so GovOPlaN must durably hand all content to its owning
|
||||||
|
workflow first.
|
||||||
|
|
||||||
|
## Ingress receipt
|
||||||
|
|
||||||
|
The configured profile binds the exact destination, Submission API version,
|
||||||
|
metadata-schema version, profile revision, and references to connection,
|
||||||
|
decryption, and event-signing credentials. Credential values never enter the
|
||||||
|
contract.
|
||||||
|
|
||||||
|
The submission evidence records SHA-256 values and authentication tags for
|
||||||
|
metadata, business data, and at most 100 uniquely identified attachments. The
|
||||||
|
receipt binds these values to submission, transaction, public service, region,
|
||||||
|
destination, profile, and receive time. It starts with both `acknowledged` and
|
||||||
|
`business_accepted` set to false.
|
||||||
|
|
||||||
|
## Acknowledgement decision
|
||||||
|
|
||||||
|
An acceptance plan requires all of the following:
|
||||||
|
|
||||||
|
- every component was downloaded;
|
||||||
|
- decryption succeeded;
|
||||||
|
- metadata and business-data schemas validated;
|
||||||
|
- all authentication tags were verified;
|
||||||
|
- an owning service, form, or case workflow durably stored the exact handoff.
|
||||||
|
|
||||||
|
Failed local download or durability always produces `defer`, leaving recovery
|
||||||
|
possible. A `reject` plan additionally requires a complete download, explicit
|
||||||
|
technical rejection classification, and bounded problem codes. Problem details
|
||||||
|
are operator evidence and are not suitable as applicant-facing messages.
|
||||||
|
|
||||||
|
The internal event request and plan are canonical and digest-bound. The request
|
||||||
|
is not a protocol-conformant SET wire payload. Plans always have
|
||||||
|
`dispatch_allowed=False`: a later target adapter must translate the request,
|
||||||
|
create and sign the Security Event Token, send it, reconcile the event log after
|
||||||
|
unknown outcomes, and prove recovery against the selected FIT-Connect
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## Datenschutz und Betrieb
|
||||||
|
|
||||||
|
Das Modul speichert derzeit weder Antragsdaten noch Anlagen, Eingangsbelege,
|
||||||
|
Pläne oder Schlüssel. Das fachlich verantwortliche Service-, Forms- oder
|
||||||
|
Cases-Modul übernimmt Daten und Aufbewahrung, bevor eine technische Bestätigung
|
||||||
|
zulässig wird. Eine technische Eingangsbestätigung ist keine fachliche Annahme
|
||||||
|
oder positive Entscheidung über den Antrag.
|
||||||
|
|
||||||
|
Für den Zieltest werden Verwaltungsleistung, Zustellpunkt, Umgebung,
|
||||||
|
API-/Metadatenschemaversion, Client und Schlüssel, Anlagengrenzen,
|
||||||
|
Fachschema, Rückkanal, Ereignisprotokoll sowie Verfahren für Zeitüberschreitung,
|
||||||
|
Doppelabruf und Wiederherstellung benötigt.
|
||||||
|
|
||||||
|
Official orientation:
|
||||||
|
|
||||||
|
- [Receiving overview](https://docs.fitko.de/fit-connect/docs/receiving/overview/)
|
||||||
|
- [Download a submission](https://docs.fitko.de/fit-connect/docs/receiving/download-submission/)
|
||||||
|
- [Verify a submission](https://docs.fitko.de/fit-connect/docs/receiving/verification/)
|
||||||
|
- [Technical receipt acknowledgement](https://docs.fitko.de/fit-connect/docs/receiving/process-and-acknowledge/)
|
||||||
|
- [FIT-Connect events](https://docs.fitko.de/fit-connect/docs/getting-started/event-log/events/)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-fit-connect"
|
||||||
|
version = "0.1.19"
|
||||||
|
description = "GovOPlaN FIT-Connect inbound receipt and acknowledgement connector."
|
||||||
|
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_fit_connect = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
fit_connect = "govoplan_fit_connect.backend.manifest:get_manifest"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN FIT-Connect integration module."""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Governed FIT-Connect inbound contracts."""
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
FIT_CONNECT_ACCEPT_EVENT = "https://schema.fitko.de/fit-connect/events/accept-submission"
|
||||||
|
FIT_CONNECT_REJECT_EVENT = "https://schema.fitko.de/fit-connect/events/reject-submission"
|
||||||
|
|
||||||
|
AcknowledgementDisposition = Literal["accept", "reject", "defer"]
|
||||||
|
FailureDisposition = Literal["retry", "reject"]
|
||||||
|
|
||||||
|
|
||||||
|
class FitConnectInboundError(RuntimeError):
|
||||||
|
"""Stable FIT-Connect receipt error without submission contents or secrets."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectProfile:
|
||||||
|
"""Exact non-secret subscriber binding; no environment is selected by default."""
|
||||||
|
|
||||||
|
profile_id: str
|
||||||
|
destination_id: str
|
||||||
|
submission_api_version: str
|
||||||
|
metadata_schema_version: str
|
||||||
|
profile_revision: str
|
||||||
|
connection_ref: str
|
||||||
|
decryption_key_ref: str
|
||||||
|
event_signing_key_ref: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"profile_id",
|
||||||
|
"submission_api_version",
|
||||||
|
"metadata_schema_version",
|
||||||
|
"profile_revision",
|
||||||
|
"connection_ref",
|
||||||
|
"decryption_key_ref",
|
||||||
|
"event_signing_key_ref",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"destination_id",
|
||||||
|
_uuid(self.destination_id, "destination_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile_sha256(self) -> str:
|
||||||
|
return _digest(
|
||||||
|
{
|
||||||
|
"profile_id": self.profile_id,
|
||||||
|
"destination_id": self.destination_id,
|
||||||
|
"submission_api_version": self.submission_api_version,
|
||||||
|
"metadata_schema_version": self.metadata_schema_version,
|
||||||
|
"profile_revision": self.profile_revision,
|
||||||
|
"connection_ref": self.connection_ref,
|
||||||
|
"decryption_key_ref": self.decryption_key_ref,
|
||||||
|
"event_signing_key_ref": self.event_signing_key_ref,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectAttachmentEvidence:
|
||||||
|
attachment_id: str
|
||||||
|
content_sha256: str
|
||||||
|
authentication_tag: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "attachment_id", _uuid(self.attachment_id, "attachment_id"))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"content_sha256",
|
||||||
|
_sha256(self.content_sha256, "attachment_content_sha256"),
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"authentication_tag",
|
||||||
|
_text(self.authentication_tag, "attachment_authentication_tag", maximum=500),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectSubmissionEvidence:
|
||||||
|
metadata_sha256: str
|
||||||
|
data_sha256: str
|
||||||
|
metadata_authentication_tag: str
|
||||||
|
data_authentication_tag: str
|
||||||
|
attachments: tuple[FitConnectAttachmentEvidence, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"metadata_sha256",
|
||||||
|
_sha256(self.metadata_sha256, "metadata_sha256"),
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "data_sha256", _sha256(self.data_sha256, "data_sha256"))
|
||||||
|
for name in ("metadata_authentication_tag", "data_authentication_tag"):
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
_text(getattr(self, name), name, maximum=500),
|
||||||
|
)
|
||||||
|
if len(self.attachments) > 100:
|
||||||
|
raise ValueError("FIT-Connect submission evidence is limited to 100 attachments.")
|
||||||
|
attachment_ids = [item.attachment_id for item in self.attachments]
|
||||||
|
if len(attachment_ids) != len(set(attachment_ids)):
|
||||||
|
raise ValueError("FIT-Connect attachment evidence identifiers must be unique.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def evidence_sha256(self) -> str:
|
||||||
|
return _digest(self.to_payload())
|
||||||
|
|
||||||
|
def to_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"metadata_sha256": self.metadata_sha256,
|
||||||
|
"data_sha256": self.data_sha256,
|
||||||
|
"metadata_authentication_tag": self.metadata_authentication_tag,
|
||||||
|
"data_authentication_tag": self.data_authentication_tag,
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"attachment_id": item.attachment_id,
|
||||||
|
"content_sha256": item.content_sha256,
|
||||||
|
"authentication_tag": item.authentication_tag,
|
||||||
|
}
|
||||||
|
for item in self.attachments
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authentication_tags_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"metadata": self.metadata_authentication_tag,
|
||||||
|
"data": self.data_authentication_tag,
|
||||||
|
"attachments": {
|
||||||
|
item.attachment_id: item.authentication_tag for item in self.attachments
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectSubmission:
|
||||||
|
destination_id: str
|
||||||
|
submission_id: str
|
||||||
|
transaction_reference: str
|
||||||
|
public_service_identifier: str
|
||||||
|
region: str | None
|
||||||
|
received_at: datetime
|
||||||
|
evidence: FitConnectSubmissionEvidence
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"destination_id",
|
||||||
|
_uuid(self.destination_id, "destination_id"),
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"submission_id",
|
||||||
|
_uuid(self.submission_id, "submission_id"),
|
||||||
|
)
|
||||||
|
for name in ("transaction_reference", "public_service_identifier"):
|
||||||
|
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||||
|
if self.region is not None:
|
||||||
|
object.__setattr__(self, "region", _text(self.region, "region", maximum=100))
|
||||||
|
_aware(self.received_at, "received_at")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def submission_sha256(self) -> str:
|
||||||
|
return _digest(
|
||||||
|
{
|
||||||
|
"destination_id": self.destination_id,
|
||||||
|
"submission_id": self.submission_id,
|
||||||
|
"transaction_reference": self.transaction_reference,
|
||||||
|
"public_service_identifier": self.public_service_identifier,
|
||||||
|
"region": self.region,
|
||||||
|
"received_at": self.received_at.isoformat(),
|
||||||
|
"evidence_sha256": self.evidence.evidence_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectIngressReceipt:
|
||||||
|
destination_id: str
|
||||||
|
submission_id: str
|
||||||
|
profile_id: str
|
||||||
|
profile_sha256: str
|
||||||
|
submission_sha256: str
|
||||||
|
evidence_sha256: str
|
||||||
|
received_at: datetime
|
||||||
|
receipt_sha256: str
|
||||||
|
acknowledged: bool = False
|
||||||
|
business_accepted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectVerification:
|
||||||
|
downloaded_complete: bool
|
||||||
|
decryption_succeeded: bool
|
||||||
|
metadata_schema_valid: bool
|
||||||
|
data_schema_valid: bool
|
||||||
|
authentication_tags_verified: bool
|
||||||
|
verified_at: datetime
|
||||||
|
durable_handoff_reference: str | None = None
|
||||||
|
durable_handoff_sha256: str | None = None
|
||||||
|
failure_disposition: FailureDisposition | None = None
|
||||||
|
problem_codes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_aware(self.verified_at, "verified_at")
|
||||||
|
paired = (self.durable_handoff_reference is None) == (
|
||||||
|
self.durable_handoff_sha256 is None
|
||||||
|
)
|
||||||
|
if not paired:
|
||||||
|
raise ValueError("FIT-Connect durable handoff reference and digest must be supplied together.")
|
||||||
|
if self.durable_handoff_reference is not None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"durable_handoff_reference",
|
||||||
|
_text(self.durable_handoff_reference, "durable_handoff_reference"),
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"durable_handoff_sha256",
|
||||||
|
_sha256(self.durable_handoff_sha256, "durable_handoff_sha256"),
|
||||||
|
)
|
||||||
|
if len(self.problem_codes) > 50:
|
||||||
|
raise ValueError("FIT-Connect verification is limited to 50 problem codes.")
|
||||||
|
problems = tuple(_text(item, "problem_code", maximum=255) for item in self.problem_codes)
|
||||||
|
if len(problems) != len(set(problems)):
|
||||||
|
raise ValueError("FIT-Connect problem codes must be unique.")
|
||||||
|
object.__setattr__(self, "problem_codes", problems)
|
||||||
|
complete = self.technically_complete
|
||||||
|
if complete and (self.failure_disposition is not None or problems):
|
||||||
|
raise ValueError("Complete FIT-Connect verification cannot carry a failure disposition.")
|
||||||
|
if not complete and self.failure_disposition == "reject" and not problems:
|
||||||
|
raise ValueError("FIT-Connect rejection requires bounded technical problem codes.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def technically_complete(self) -> bool:
|
||||||
|
return all(
|
||||||
|
(
|
||||||
|
self.downloaded_complete,
|
||||||
|
self.decryption_succeeded,
|
||||||
|
self.metadata_schema_valid,
|
||||||
|
self.data_schema_valid,
|
||||||
|
self.authentication_tags_verified,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def durable(self) -> bool:
|
||||||
|
return self.durable_handoff_reference is not None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FitConnectAcknowledgementPlan:
|
||||||
|
destination_id: str
|
||||||
|
submission_id: str
|
||||||
|
disposition: AcknowledgementDisposition
|
||||||
|
event_type: str | None
|
||||||
|
receipt_sha256: str
|
||||||
|
profile_sha256: str
|
||||||
|
submission_sha256: str
|
||||||
|
durable_handoff_reference: str | None
|
||||||
|
durable_handoff_sha256: str | None
|
||||||
|
problem_codes: tuple[str, ...]
|
||||||
|
event_request_json: bytes | None
|
||||||
|
event_request_sha256: str | None
|
||||||
|
plan_sha256: str
|
||||||
|
dispatch_allowed: bool = False
|
||||||
|
technical_receipt_only: bool = True
|
||||||
|
business_accepted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def create_ingress_receipt(
|
||||||
|
profile: FitConnectProfile,
|
||||||
|
submission: FitConnectSubmission,
|
||||||
|
) -> FitConnectIngressReceipt:
|
||||||
|
if submission.destination_id != profile.destination_id:
|
||||||
|
raise FitConnectInboundError("FIT-Connect submission belongs to another destination.")
|
||||||
|
payload = {
|
||||||
|
"destination_id": submission.destination_id,
|
||||||
|
"submission_id": submission.submission_id,
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"profile_sha256": profile.profile_sha256,
|
||||||
|
"submission_sha256": submission.submission_sha256,
|
||||||
|
"evidence_sha256": submission.evidence.evidence_sha256,
|
||||||
|
"received_at": submission.received_at.isoformat(),
|
||||||
|
}
|
||||||
|
return FitConnectIngressReceipt(
|
||||||
|
destination_id=submission.destination_id,
|
||||||
|
submission_id=submission.submission_id,
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
profile_sha256=profile.profile_sha256,
|
||||||
|
submission_sha256=submission.submission_sha256,
|
||||||
|
evidence_sha256=submission.evidence.evidence_sha256,
|
||||||
|
received_at=submission.received_at,
|
||||||
|
receipt_sha256=_digest(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_acknowledgement_plan(
|
||||||
|
profile: FitConnectProfile,
|
||||||
|
submission: FitConnectSubmission,
|
||||||
|
receipt: FitConnectIngressReceipt,
|
||||||
|
verification: FitConnectVerification,
|
||||||
|
) -> FitConnectAcknowledgementPlan:
|
||||||
|
_verify_binding(profile, submission, receipt)
|
||||||
|
if verification.technically_complete and verification.durable:
|
||||||
|
disposition: AcknowledgementDisposition = "accept"
|
||||||
|
event_type = FIT_CONNECT_ACCEPT_EVENT
|
||||||
|
event_request: dict[str, object] | None = {
|
||||||
|
"event": event_type,
|
||||||
|
"submission_id": submission.submission_id,
|
||||||
|
"transaction_reference": submission.transaction_reference,
|
||||||
|
"authentication_tags": submission.evidence.authentication_tags_payload(),
|
||||||
|
"durable_handoff_reference": verification.durable_handoff_reference,
|
||||||
|
"durable_handoff_sha256": verification.durable_handoff_sha256,
|
||||||
|
}
|
||||||
|
problems: tuple[str, ...] = ()
|
||||||
|
elif (
|
||||||
|
not verification.technically_complete
|
||||||
|
and verification.failure_disposition == "reject"
|
||||||
|
and verification.problem_codes
|
||||||
|
and verification.downloaded_complete
|
||||||
|
):
|
||||||
|
disposition = "reject"
|
||||||
|
event_type = FIT_CONNECT_REJECT_EVENT
|
||||||
|
problems = verification.problem_codes
|
||||||
|
event_request = {
|
||||||
|
"event": event_type,
|
||||||
|
"submission_id": submission.submission_id,
|
||||||
|
"transaction_reference": submission.transaction_reference,
|
||||||
|
"problem_codes": list(problems),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
disposition = "defer"
|
||||||
|
event_type = None
|
||||||
|
event_request = None
|
||||||
|
problems = verification.problem_codes
|
||||||
|
event_request_json = _canonical_json(event_request) if event_request is not None else None
|
||||||
|
event_request_sha256 = (
|
||||||
|
hashlib.sha256(event_request_json).hexdigest() if event_request_json is not None else None
|
||||||
|
)
|
||||||
|
plan_payload = {
|
||||||
|
"destination_id": submission.destination_id,
|
||||||
|
"submission_id": submission.submission_id,
|
||||||
|
"disposition": disposition,
|
||||||
|
"event_type": event_type,
|
||||||
|
"receipt_sha256": receipt.receipt_sha256,
|
||||||
|
"profile_sha256": profile.profile_sha256,
|
||||||
|
"submission_sha256": submission.submission_sha256,
|
||||||
|
"verified_at": verification.verified_at.isoformat(),
|
||||||
|
"durable_handoff_reference": verification.durable_handoff_reference,
|
||||||
|
"durable_handoff_sha256": verification.durable_handoff_sha256,
|
||||||
|
"problem_codes": list(problems),
|
||||||
|
"event_request_sha256": event_request_sha256,
|
||||||
|
}
|
||||||
|
return FitConnectAcknowledgementPlan(
|
||||||
|
destination_id=submission.destination_id,
|
||||||
|
submission_id=submission.submission_id,
|
||||||
|
disposition=disposition,
|
||||||
|
event_type=event_type,
|
||||||
|
receipt_sha256=receipt.receipt_sha256,
|
||||||
|
profile_sha256=profile.profile_sha256,
|
||||||
|
submission_sha256=submission.submission_sha256,
|
||||||
|
durable_handoff_reference=verification.durable_handoff_reference,
|
||||||
|
durable_handoff_sha256=verification.durable_handoff_sha256,
|
||||||
|
problem_codes=problems,
|
||||||
|
event_request_json=event_request_json,
|
||||||
|
event_request_sha256=event_request_sha256,
|
||||||
|
plan_sha256=_digest(plan_payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_binding(
|
||||||
|
profile: FitConnectProfile,
|
||||||
|
submission: FitConnectSubmission,
|
||||||
|
receipt: FitConnectIngressReceipt,
|
||||||
|
) -> None:
|
||||||
|
checks = (
|
||||||
|
(submission.destination_id == profile.destination_id, "submission destination"),
|
||||||
|
(receipt.destination_id == profile.destination_id, "receipt destination"),
|
||||||
|
(receipt.submission_id == submission.submission_id, "receipt submission"),
|
||||||
|
(receipt.profile_id == profile.profile_id, "receipt profile"),
|
||||||
|
(receipt.profile_sha256 == profile.profile_sha256, "receipt profile digest"),
|
||||||
|
(receipt.submission_sha256 == submission.submission_sha256, "receipt submission digest"),
|
||||||
|
(
|
||||||
|
receipt.evidence_sha256 == submission.evidence.evidence_sha256,
|
||||||
|
"receipt evidence digest",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mismatch = next((label for valid, label in checks if not valid), None)
|
||||||
|
if mismatch is not None:
|
||||||
|
raise FitConnectInboundError(f"FIT-Connect {mismatch} does not match the exact ingress.")
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(value: object, label: str) -> str:
|
||||||
|
try:
|
||||||
|
return str(UUID(str(value or "").strip()))
|
||||||
|
except (ValueError, AttributeError) as exc:
|
||||||
|
raise ValueError(f"FIT-Connect {label.replace('_', ' ')} must be a UUID.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object, label: str, *, maximum: int = 255) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized or len(normalized) > maximum or any(ord(char) < 32 for char in normalized):
|
||||||
|
raise ValueError(
|
||||||
|
f"FIT-Connect {label.replace('_', ' ')} is required, bounded, and must not contain controls."
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(value: object, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip().lower().removeprefix("sha256:")
|
||||||
|
if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized):
|
||||||
|
raise ValueError(f"FIT-Connect {label.replace('_', ' ')} must be a SHA-256 digest.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime, label: str) -> None:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise ValueError(f"FIT-Connect {label.replace('_', ' ')} must be timezone-aware.")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FIT_CONNECT_ACCEPT_EVENT",
|
||||||
|
"FIT_CONNECT_REJECT_EVENT",
|
||||||
|
"FitConnectAcknowledgementPlan",
|
||||||
|
"FitConnectAttachmentEvidence",
|
||||||
|
"FitConnectInboundError",
|
||||||
|
"FitConnectIngressReceipt",
|
||||||
|
"FitConnectProfile",
|
||||||
|
"FitConnectSubmission",
|
||||||
|
"FitConnectSubmissionEvidence",
|
||||||
|
"FitConnectVerification",
|
||||||
|
"build_acknowledgement_plan",
|
||||||
|
"create_ingress_receipt",
|
||||||
|
]
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
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,
|
||||||
|
ModuleManifest,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderDeclaration,
|
||||||
|
ExternalProviderRuntimeState,
|
||||||
|
ExternalProviderStateContext,
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
ProviderBehaviorDeclaration,
|
||||||
|
ProviderObjectDeclaration,
|
||||||
|
declared_module_architecture,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "fit_connect"
|
||||||
|
MODULE_VERSION = "0.1.19"
|
||||||
|
READ_SCOPE = "fit_connect:submissions:read"
|
||||||
|
VERIFY_SCOPE = "fit_connect:submissions:verify"
|
||||||
|
ACK_SCOPE = "fit_connect:acknowledgements:plan"
|
||||||
|
ADMIN_SCOPE = "fit_connect:integration:admin"
|
||||||
|
FIT_CONNECT_PROVIDER_ID = "fit_connect.submission_api"
|
||||||
|
|
||||||
|
|
||||||
|
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="FIT-Connect",
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FIT_CONNECT_PROVIDER = ExternalProviderDeclaration(
|
||||||
|
id=FIT_CONNECT_PROVIDER_ID,
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="FIT-Connect Submission API subscriber",
|
||||||
|
maturity="read",
|
||||||
|
operations=("read", "preview", "dry_run"),
|
||||||
|
objects=(
|
||||||
|
ProviderObjectDeclaration(
|
||||||
|
object_type="inbound_submission",
|
||||||
|
field_groups=("transport_identity", "metadata", "data", "attachments", "authentication_tags"),
|
||||||
|
authority_modes=("external_authoritative",),
|
||||||
|
default_authority_mode="external_authoritative",
|
||||||
|
),
|
||||||
|
ProviderObjectDeclaration(
|
||||||
|
object_type="technical_acknowledgement_plan",
|
||||||
|
field_groups=("receipt", "verification", "event", "handoff"),
|
||||||
|
authority_modes=("native_authoritative",),
|
||||||
|
default_authority_mode="native_authoritative",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
behavior=ProviderBehaviorDeclaration(
|
||||||
|
revision_tokens=(
|
||||||
|
"Destination profile, API version, metadata schema version, submission id, "
|
||||||
|
"transaction reference, and content evidence digests are retained."
|
||||||
|
),
|
||||||
|
concurrency=(
|
||||||
|
"Acknowledgement planning is bound to one exact ingress receipt and verification result."
|
||||||
|
),
|
||||||
|
freshness=(
|
||||||
|
"Ingress and verification timestamps are timezone-aware; remote availability must be observed separately."
|
||||||
|
),
|
||||||
|
health=(
|
||||||
|
"Retrieval, decryption, schema validation, authentication tags, durable handoff, "
|
||||||
|
"event signing, submission, and reconciliation are separate gates."
|
||||||
|
),
|
||||||
|
max_read_items=1000,
|
||||||
|
idempotency=(
|
||||||
|
"Submission id, destination id, receipt digest, and acknowledgement-plan digest form the correlation."
|
||||||
|
),
|
||||||
|
retry=(
|
||||||
|
"Deferred acknowledgements retain the remote submission; event retry requires event-log reconciliation."
|
||||||
|
),
|
||||||
|
timeout_seconds=30,
|
||||||
|
conflicts=(
|
||||||
|
"Destination, profile, submission, content, receipt, or authentication-tag mismatches block acknowledgement."
|
||||||
|
),
|
||||||
|
outcome_unknown=(
|
||||||
|
"An event timeout is unknown until the FIT-Connect event log is checked by exact submission correlation."
|
||||||
|
),
|
||||||
|
outcome_unknown_supported=True,
|
||||||
|
evidence=(
|
||||||
|
"Submission, content, authentication-tag, receipt, handoff, event-request, and plan digests form evidence."
|
||||||
|
),
|
||||||
|
correction=(
|
||||||
|
"Retry local verification or create a new plan; never relabel technical receipt as business approval."
|
||||||
|
),
|
||||||
|
rollback=(
|
||||||
|
"Acceptance or rejection may transition or delete the service-side submission and is not assumed reversible."
|
||||||
|
),
|
||||||
|
compensation=(
|
||||||
|
"Use the module-owned case and a governed reply channel for later business correction."
|
||||||
|
),
|
||||||
|
reconciliation=(
|
||||||
|
"Read the signed event log and verify event identity, submission binding, issuer, and authentication tags before retry."
|
||||||
|
),
|
||||||
|
outage=(
|
||||||
|
"Do not acknowledge until all content is durably handed to its owning GovOPlaN workflow."
|
||||||
|
),
|
||||||
|
classifications=("confidential", "restricted"),
|
||||||
|
purposes=("application receipt", "technical receipt acknowledgement"),
|
||||||
|
retention=(
|
||||||
|
"The owning service or case module retains application data; this connector retains only governed transport evidence when persistence is added."
|
||||||
|
),
|
||||||
|
secret_handling=(
|
||||||
|
"OAuth, decryption, and event-signing material are credential references and never enter plans or diagnostics."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation_topic_ids=("fit-connect.inbound-receipt",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_states(
|
||||||
|
context: ExternalProviderStateContext,
|
||||||
|
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||||
|
del context
|
||||||
|
return (
|
||||||
|
ExternalProviderRuntimeState(
|
||||||
|
provider_id=FIT_CONNECT_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 FIT-Connect destination binding is configured; event dispatch is disabled.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name="FIT-Connect",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("access",),
|
||||||
|
optional_dependencies=("portal", "forms_runtime", "services", "cases", "files", "audit", "policy"),
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
_permission(READ_SCOPE, "View FIT-Connect ingress", "Read bounded transport receipts and non-secret verification state."),
|
||||||
|
_permission(VERIFY_SCOPE, "Verify FIT-Connect submission", "Record bounded download, decryption, schema, and authentication-tag evidence."),
|
||||||
|
_permission(ACK_SCOPE, "Plan FIT-Connect acknowledgement", "Create an effect-free technical accept, reject, or defer plan."),
|
||||||
|
_permission(ADMIN_SCOPE, "Administer FIT-Connect integration", "Configure and test destination, key, event, and recovery bindings."),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="fit_connect_receiver",
|
||||||
|
name="FIT-Connect receiver",
|
||||||
|
description="Verify inbound submissions and plan technical acknowledgements.",
|
||||||
|
permissions=(READ_SCOPE, VERIFY_SCOPE, ACK_SCOPE),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="fit_connect_administrator",
|
||||||
|
name="FIT-Connect administrator",
|
||||||
|
description="Configure and verify governed FIT-Connect destination bindings.",
|
||||||
|
permissions=(READ_SCOPE, VERIFY_SCOPE, ACK_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
external_providers=(FIT_CONNECT_PROVIDER,),
|
||||||
|
external_provider_state_providers=(
|
||||||
|
ExternalProviderStateProviderRegistration(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
provider_id=FIT_CONNECT_PROVIDER_ID,
|
||||||
|
provider=_provider_states,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="fit-connect.boundary",
|
||||||
|
title="FIT-Connect integration boundary",
|
||||||
|
summary="Receive public-service transport evidence while service, form, and case modules retain business ownership.",
|
||||||
|
body=(
|
||||||
|
"FIT-Connect owns destination and subscriber profiles, bounded transport receipts, verification evidence, and technical acknowledgement plans. It does not own application semantics, case decisions, applicant communication, or business acceptance. A technical accept-submission event proves receipt and technical processability only."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("portal", "forms_runtime", "services", "cases", "audit"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="FIT-Connect integration boundary",
|
||||||
|
href="docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Integrationsgrenze des FIT-Connect-Moduls",
|
||||||
|
"summary": "Nachweise des Verwaltungsleistungstransports empfangen, während Service-, Formular- und Fallmodule die fachliche Verantwortung behalten.",
|
||||||
|
"body": "FIT-Connect verantwortet Zustellpunkt- und Abonnentenprofile, begrenzte Transportbelege, Prüfnachweise und Pläne für technische Bestätigungen. Antragssemantik, Fallentscheidungen, Kommunikation mit Antragstellenden und fachliche Annahme gehören nicht zum Modul. Ein technisches accept-submission-Ereignis belegt ausschließlich Empfang und technische Verarbeitbarkeit.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order=90,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="fit-connect.inbound-receipt",
|
||||||
|
title="Receive and technically acknowledge a FIT-Connect submission",
|
||||||
|
summary=(
|
||||||
|
"Bind the exact downloaded submission and authentication tags to a receipt, "
|
||||||
|
"then accept only after complete verification and durable handoff."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"A configured subscriber profile names the exact destination, Submission API and "
|
||||||
|
"metadata-schema versions, profile revision, and non-secret references to connection, "
|
||||||
|
"decryption, and event-signing material. The ingress receipt binds submission, "
|
||||||
|
"transaction, public service, region, metadata, data, attachments, and authentication "
|
||||||
|
"tags without claiming acknowledgement or business approval. An accept-submission plan "
|
||||||
|
"requires complete download, successful decryption, valid metadata and business-data "
|
||||||
|
"schemas, verified authentication tags, and a durable handoff to an owning module. A "
|
||||||
|
"local durability failure always defers. A rejection requires explicit bounded technical "
|
||||||
|
"problem codes and a complete download. Every plan remains non-dispatchable until signed "
|
||||||
|
"SET creation, target submission, event-log reconciliation, and recovery are tested."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("portal", "forms_runtime", "services", "cases", "files", "audit"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(READ_SCOPE, VERIFY_SCOPE, ACK_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="FIT-Connect inbound receipt contract",
|
||||||
|
href="docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "FIT-Connect-Einreichung empfangen und technisch bestätigen",
|
||||||
|
"summary": "Die exakt heruntergeladene Einreichung und ihre Authentication-Tags an einen Eingangsbeleg binden und erst nach vollständiger Prüfung und dauerhafter Übergabe bestätigen.",
|
||||||
|
"body": "Ein Abonnentenprofil bezeichnet Zustellpunkt, Versionen von Submission API und Metadatenschema, Profilrevision sowie nicht geheime Referenzen auf Verbindung, Entschlüsselung und Ereignissignatur. Der Eingangsbeleg bindet Einreichung, Transaktion, Verwaltungsleistung, Region, Metadaten, Fachdaten, Anlagen und Authentication-Tags, ohne eine Bestätigung oder fachliche Annahme zu behaupten. Ein Plan für accept-submission setzt vollständigen Abruf, erfolgreiche Entschlüsselung, gültige Meta- und Fachdaten, geprüfte Authentication-Tags sowie die dauerhafte Übergabe an ein fachlich verantwortliches Modul voraus. Lokale Speicherfehler führen immer zum Aufschub. Eine Zurückweisung braucht ausdrücklich geprüfte technische Problemcodes und einen vollständigen Abruf. Jeder Plan bleibt wirkungslos, bis SET-Signatur, Zielversand, Ereignisprotokollabgleich und Wiederherstellung getestet sind.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"prerequisites": [
|
||||||
|
"A destination and subscriber client exist in an approved FIT-Connect environment.",
|
||||||
|
"The deployment can resolve decryption and event-signing key references.",
|
||||||
|
"An owning service, form, or case workflow can durably accept the exact submission.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Retrieve every encrypted component and attachment for the exact submission id.",
|
||||||
|
"Decrypt and validate metadata, business data, attachments, and authentication tags.",
|
||||||
|
"Persist a digest-bound handoff in the owning workflow before acknowledgement.",
|
||||||
|
"Plan accept, reject, or defer and reconcile any dispatched SET in the event log.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"No environment, journey, destination, API profile, or credentials are activated by default.",
|
||||||
|
"This release creates receipts and acknowledgement plans but does not create or send signed SETs.",
|
||||||
|
"Technical acknowledgement is never business acceptance of the application.",
|
||||||
|
],
|
||||||
|
"consequences": [
|
||||||
|
"Successful accept or reject processing may remove the submission from the delivery service.",
|
||||||
|
"Incomplete local durability defers the event and preserves recovery options.",
|
||||||
|
"Technical problem details are operator evidence and must not be exposed directly to applicants.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="data_reporting_integration",
|
||||||
|
kind="integration",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",
|
||||||
|
test_ref="tests/test_inbound.py",
|
||||||
|
known_limits=(
|
||||||
|
"A concrete FIT-Connect environment, destination, journey, credentials, signed-SET adapter, and target recovery test are required before event dispatch.",
|
||||||
|
),
|
||||||
|
supported_authority_modes=("external_authoritative", "native_authoritative"),
|
||||||
|
owned_concepts=("FIT-Connect subscriber profile", "ingress receipt", "technical acknowledgement plan"),
|
||||||
|
non_owned_concepts=("application", "case", "business acceptance", "submission file storage"),
|
||||||
|
recovery_docs=("docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",),
|
||||||
|
security_docs=("docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",),
|
||||||
|
operations_docs=("docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_fit_connect.backend.inbound import (
|
||||||
|
FIT_CONNECT_ACCEPT_EVENT,
|
||||||
|
FIT_CONNECT_REJECT_EVENT,
|
||||||
|
FitConnectAttachmentEvidence,
|
||||||
|
FitConnectInboundError,
|
||||||
|
FitConnectProfile,
|
||||||
|
FitConnectSubmission,
|
||||||
|
FitConnectSubmissionEvidence,
|
||||||
|
FitConnectVerification,
|
||||||
|
build_acknowledgement_plan,
|
||||||
|
create_ingress_receipt,
|
||||||
|
)
|
||||||
|
from govoplan_fit_connect.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
DESTINATION_ID = "736c4581-da80-4710-9384-d19ebe1ff2bc"
|
||||||
|
SUBMISSION_ID = "c82585d8-e49d-4e27-abd6-be3dbf61a76f"
|
||||||
|
|
||||||
|
|
||||||
|
def _profile() -> FitConnectProfile:
|
||||||
|
return FitConnectProfile(
|
||||||
|
profile_id="fit-connect-test-binding",
|
||||||
|
destination_id=DESTINATION_ID,
|
||||||
|
submission_api_version="configured-exact-version",
|
||||||
|
metadata_schema_version="configured-exact-version",
|
||||||
|
profile_revision="revision-4",
|
||||||
|
connection_ref="core-credential:fit-connect-client",
|
||||||
|
decryption_key_ref="core-key:fit-connect-decryption",
|
||||||
|
event_signing_key_ref="core-key:fit-connect-event-signing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _submission() -> FitConnectSubmission:
|
||||||
|
return FitConnectSubmission(
|
||||||
|
destination_id=DESTINATION_ID,
|
||||||
|
submission_id=SUBMISSION_ID,
|
||||||
|
transaction_reference="case:transaction-7",
|
||||||
|
public_service_identifier="urn:de:fim:leika:leistung:99102013104000",
|
||||||
|
region="DE094750156156",
|
||||||
|
received_at=datetime(2026, 8, 23, 12, tzinfo=UTC),
|
||||||
|
evidence=FitConnectSubmissionEvidence(
|
||||||
|
metadata_sha256="a" * 64,
|
||||||
|
data_sha256="b" * 64,
|
||||||
|
metadata_authentication_tag="metadata-tag",
|
||||||
|
data_authentication_tag="data-tag",
|
||||||
|
attachments=(
|
||||||
|
FitConnectAttachmentEvidence(
|
||||||
|
attachment_id="19ffb5ed-0a9d-02b2-9bfb-e271a8474c61",
|
||||||
|
content_sha256="c" * 64,
|
||||||
|
authentication_tag="attachment-tag",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verification(**changes) -> FitConnectVerification:
|
||||||
|
values = {
|
||||||
|
"downloaded_complete": True,
|
||||||
|
"decryption_succeeded": True,
|
||||||
|
"metadata_schema_valid": True,
|
||||||
|
"data_schema_valid": True,
|
||||||
|
"authentication_tags_verified": True,
|
||||||
|
"verified_at": datetime(2026, 8, 23, 12, 5, tzinfo=UTC),
|
||||||
|
"durable_handoff_reference": "cases:case-4:submission-1",
|
||||||
|
"durable_handoff_sha256": "d" * 64,
|
||||||
|
}
|
||||||
|
values.update(changes)
|
||||||
|
return FitConnectVerification(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingress_receipt_is_exact_and_claims_no_acknowledgement() -> None:
|
||||||
|
receipt = create_ingress_receipt(_profile(), _submission())
|
||||||
|
|
||||||
|
assert receipt.acknowledged is False
|
||||||
|
assert receipt.business_accepted is False
|
||||||
|
assert len(receipt.receipt_sha256) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_durable_verification_builds_effect_free_accept_plan() -> None:
|
||||||
|
profile = _profile()
|
||||||
|
submission = _submission()
|
||||||
|
receipt = create_ingress_receipt(profile, submission)
|
||||||
|
|
||||||
|
plan = build_acknowledgement_plan(
|
||||||
|
profile,
|
||||||
|
submission,
|
||||||
|
receipt,
|
||||||
|
_verification(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.disposition == "accept"
|
||||||
|
assert plan.event_type == FIT_CONNECT_ACCEPT_EVENT
|
||||||
|
assert plan.dispatch_allowed is False
|
||||||
|
assert plan.technical_receipt_only is True
|
||||||
|
assert plan.business_accepted is False
|
||||||
|
payload = json.loads(plan.event_request_json)
|
||||||
|
assert payload["authentication_tags"]["metadata"] == "metadata-tag"
|
||||||
|
assert payload["durable_handoff_sha256"] == "d" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_verification_without_durable_handoff_defers() -> None:
|
||||||
|
profile = _profile()
|
||||||
|
submission = _submission()
|
||||||
|
receipt = create_ingress_receipt(profile, submission)
|
||||||
|
verification = _verification(
|
||||||
|
durable_handoff_reference=None,
|
||||||
|
durable_handoff_sha256=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = build_acknowledgement_plan(profile, submission, receipt, verification)
|
||||||
|
|
||||||
|
assert plan.disposition == "defer"
|
||||||
|
assert plan.event_type is None
|
||||||
|
assert plan.event_request_json is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_download_cannot_be_turned_into_rejection() -> None:
|
||||||
|
profile = _profile()
|
||||||
|
submission = _submission()
|
||||||
|
receipt = create_ingress_receipt(profile, submission)
|
||||||
|
verification = _verification(
|
||||||
|
downloaded_complete=False,
|
||||||
|
durable_handoff_reference=None,
|
||||||
|
durable_handoff_sha256=None,
|
||||||
|
failure_disposition="reject",
|
||||||
|
problem_codes=("download-incomplete",),
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = build_acknowledgement_plan(profile, submission, receipt, verification)
|
||||||
|
|
||||||
|
assert plan.disposition == "defer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_permanent_technical_failure_builds_reject_plan() -> None:
|
||||||
|
profile = _profile()
|
||||||
|
submission = _submission()
|
||||||
|
receipt = create_ingress_receipt(profile, submission)
|
||||||
|
verification = _verification(
|
||||||
|
authentication_tags_verified=False,
|
||||||
|
durable_handoff_reference=None,
|
||||||
|
durable_handoff_sha256=None,
|
||||||
|
failure_disposition="reject",
|
||||||
|
problem_codes=("authentication-tags-invalid",),
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = build_acknowledgement_plan(profile, submission, receipt, verification)
|
||||||
|
|
||||||
|
assert plan.disposition == "reject"
|
||||||
|
assert plan.event_type == FIT_CONNECT_REJECT_EVENT
|
||||||
|
assert json.loads(plan.event_request_json)["problem_codes"] == [
|
||||||
|
"authentication-tags-invalid"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_from_another_submission_is_rejected() -> None:
|
||||||
|
profile = _profile()
|
||||||
|
submission = _submission()
|
||||||
|
receipt = create_ingress_receipt(profile, submission)
|
||||||
|
wrong_receipt = replace(
|
||||||
|
receipt,
|
||||||
|
submission_id="02bf1d9f-282d-4abf-810a-c4104baf0afe",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FitConnectInboundError, match="receipt submission"):
|
||||||
|
build_acknowledgement_plan(profile, submission, wrong_receipt, _verification())
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_declares_no_active_target_and_has_german_documentation() -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
assert manifest.version == "0.1.19"
|
||||||
|
assert manifest.external_providers[0].id == "fit_connect.submission_api"
|
||||||
|
assert manifest.architecture is not None
|
||||||
|
assert manifest.architecture.target_tested_providers == ()
|
||||||
|
assert manifest.documentation[0].translations["de"]["title"]
|
||||||
Reference in New Issue
Block a user