Compare commits

..
Author SHA1 Message Date
notandClaude Opus 5 dfa1a370ac ci(deploy): publish the stack over TLS when PUBLIC_DOMAIN is set (refs #175)
The in-cluster edge (#178) is off unless the chart is given a domain, so pass
one through from a repository variable. Unset, the deploy is exactly what it was
— NodePorts, and the portals reachable only over the SSH forwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:31:47 +02:00
notandClaude Opus 5 e8cb1ec7e9 docs(k8s): how to reach the deployed portals from a laptop (refs #175)
The five forwards are not optional and not independent: the portals' OIDC
authority is pinned to localhost:30180, so forwarding the portal without
Keycloak gets ERR_CONNECTION_REFUSED on the discovery document and an opaque
"[object Object]" in the console. One ssh replaces `make k8s-portals` for the
lab server, and needs no kubeconfig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:19:02 +02:00
notandClaude Opus 5 de6db7d35b docs(k8s): split the taint fix from the registry patch (refs #175)
Talos 1.14 rejects a `patch mc` that sets `cluster.allowSchedulingOnControlPlanes`
— the field left the v1alpha1 schema, the way `machine.install` did — and the
rejection discards the rest of the patch with it, so the registry mirror never
lands and the failure moves from Pending pods to ImagePullBackOff without ever
saying so. Document the two as separate steps, and correct the claim that the
taint has to be patched away: `kubectl taint` is what §1 prescribes and it holds
until the node re-registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:06:24 +02:00
notandClaude Opus 5 d17e79959b ci(deploy): say why a deploy stalled, and document the cluster prerequisite (refs #175)
The first run against the lab server's VM timed out on `kubectl rollout status`
for the in-cluster registry with nothing but "timed out waiting for the
condition". The cause was three commands up the runbook: that VM was installed
from a stock Talos config, so the only node still carries the control-plane
taint and no pod can schedule — and the missing registry mirror would have
failed the image pulls right after.

`rollout status` can only ever report the symptom, so dump the whole cluster's
pods and the recent events on failure instead of `big`'s pods alone; the
scheduler's "untolerated taint" message is the answer and it lives in the
events. Runbook §9 now opens with the machine-config patch the deploy assumes,
as one applied-live patch rather than a `kubectl taint` that the controller
undoes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:01:47 +02:00
notandClaude Opus 5 fc036d53d5 ci(deploy): deploy the stack to Talos on merge to main (refs #175)
CI / k8s (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 1m45s
CI / mutation (pull_request) Successful in 3m1s
CI / verify-stack (pull_request) Successful in 6m22s
The chart has been deployable by hand since #25 and linted in CI since #168;
this makes a merged PR actually ship it to the lab server's Talos VM.

Neither the Kubernetes API nor the in-cluster registry is publicly reachable,
so the job forwards 6443, 30500 and 30141 over the same SSH hop into the Fedora
host that the Gitea-runner pipeline uses. That splits the registry into two
names for one store: images are pushed through the tunnel to localhost:30500,
and the node pulls them from its own NodePort — the address its registry-mirror
patch trusts over plain HTTP.

It deploys with `make k8s-reseed` rather than `make k8s-up`: the bootstrap Jobs
are idempotent, and deleting them first is what stops a changed Job template
from wedging `helm upgrade`. The nine deployments are then rolled explicitly,
because `dev` is a mutable tag and helm sees an unchanged pod template.

PR CI is the merge gate, so this workflow does not re-run the checks. Deploys
queue instead of cancelling: a `helm upgrade` killed half-way leaves the release
in `pending-upgrade` and needs unwedging by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:40:42 +02:00
48 changed files with 125 additions and 1187 deletions
+1 -23
View File
@@ -98,17 +98,6 @@ jobs:
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
# The docs site must build with --strict (#173). setup-python so `make docs` can
# create its venv regardless of what the runner image ships.
docs:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-python@v5
with:
python-version: '3.12'
- run: make docs
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
runs-on: ubuntu-latest
@@ -218,14 +207,8 @@ jobs:
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
# re-run exercises verify-stack, so we still get the signal.
#
# Main only, not on PRs: the runner shares the lab node with the deployed stack, and a second
# full stack per PR was what got the runner OOM-killed (#182). PRs still gate on every job above;
# the live-stack check runs once per merge. A plain event `if` keeps the implicit success(), so it
# is not the status-function case from gotchas §7.
verify-stack:
needs: [mutation]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
@@ -248,9 +231,6 @@ jobs:
- name: RegisterRecord objecttype registered + published
id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ClamAV scans a stream (EICAR found, clean OK)
id: clamav
run: CLAMAV_TIMEOUT=120 make verify-clamav
- name: ACL ↔ OpenZaak integration tests
id: acl
run: make verify-acl
@@ -290,7 +270,6 @@ jobs:
OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
CLAMAV: ${{ steps.clamav.outcome }}
ACL: ${{ steps.acl.outcome }}
NRC: ${{ steps.nrc.outcome }}
PROJECTION: ${{ steps.projection.outcome }}
@@ -313,7 +292,6 @@ jobs:
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
echo "| ClamAV INSTREAM scan | $(icon "$CLAMAV") |"
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
echo "| OpenZaak → NRC | $(icon "$NRC") |"
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
@@ -333,7 +311,7 @@ jobs:
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init clamav tempo prometheus grafana 2>&1 || true
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
+15 -13
View File
@@ -27,16 +27,10 @@ jobs:
# `kubectl port-forward` — runbook §5. Override with repo variables.
TALOS_VM_IP: ${{ vars.TALOS_VM_IP }}
TALOS_HOST: ${{ vars.TALOS_HOST }}
# Set it when the labs Caddy publishes the portals: Keycloak's public https
# origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing
# through the labs Caddy").
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
# `true` fills in the medewerker OTP step for the public demo (chart value
# demo.otpAutofill). The fixture secret is committed: demo only.
OTP_AUTOFILL: ${{ vars.OTP_AUTOFILL }}
# Tempo for the services' traces, e.g. http://tempo.monitoring.svc:4317 (the
# cluster monitoring stack, Infra repo). Empty = the chart default.
OTEL_ENDPOINT: ${{ vars.OTEL_ENDPOINT }}
# Set it and the stack is published over TLS on <sub>.<domain> by the
# in-cluster edge (ADR-0035, runbook §10). Empty = NodePorts, as before.
PUBLIC_DOMAIN: ${{ vars.PUBLIC_DOMAIN }}
PUBLIC_EMAIL: ${{ vars.PUBLIC_EMAIL }}
steps:
- uses: https://github.com/actions/checkout@v4
@@ -100,10 +94,12 @@ jobs:
# Job template from wedging the upgrade (`cannot patch … with kind Job`).
- name: Deploy the chart
run: |
set -euo pipefail
publish="${PUBLIC_DOMAIN:+--set public.domain=$PUBLIC_DOMAIN --set public.email=${PUBLIC_EMAIL:-}}"
make k8s-reseed \
TALOS_HOST=${TALOS_HOST:-localhost} \
K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \
K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL} --set demo.otpAutofill=${OTP_AUTOFILL:-false}${OTEL_ENDPOINT:+ --set otelEndpoint=$OTEL_ENDPOINT}"
K8S_SET="$publish"
# `dev` is a mutable tag and helm sees an unchanged pod template, so the
# new images only land on a restart (pullPolicy is already Always).
@@ -119,6 +115,12 @@ jobs:
- name: Smoke the public register
run: curl -fsS --retry 10 --retry-delay 6 --retry-all-errors http://localhost:30141/openbaar/register
- name: Pods on failure
# Cluster-wide, not just `big`: the first thing that can fail is the registry
# in its own namespace, and a scheduling problem shows up in the events, not
# in `rollout status` — which only ever says "timed out waiting".
- name: Pods and events on failure
if: failure()
run: kubectl -n big get pods,jobs || true
run: |
kubectl get pods -A -o wide || true
kubectl -n big get jobs || true
kubectl get events -A --sort-by=.lastTimestamp | tail -30 || true
-4
View File
@@ -61,7 +61,3 @@ __pycache__/
TestResults/
test-output/
tests/e2e/playwright-report.json
# MkDocs build (`make docs`)
.venv-docs/
site/
-8
View File
@@ -334,14 +334,6 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
**Outcome:** All runbooks complete: startup, seed, common failures, upgrade upstream modules, restore from backup, rotate secrets, Gitea Actions gotchas.
### S-28 · ClamAV (clamd) runs in compose and on the cluster — #191
**Outcome:** a clamd service with current signatures runs alongside the stack (compose + Helm), health-gated, with a `verify-clamav` check (EICAR → FOUND). ADR-0036 (#190).
### S-29 · Uploaded diplomas are virus-scanned and PDF-checked before storage — #192
**Outcome:** the domain stores a diploma only when it starts with `%PDF-` and clamd scans it clean; infected → 422 "infected", non-PDF → 422 "not-a-pdf", scanner down → 503. The portal explains each one.
---
## How to add a new slice
+3 -18
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten clamav
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
@@ -43,11 +43,11 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend docs integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-clamav verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
ci: lint build unit mutation frontend docs verify
ci: lint build unit mutation frontend verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
@@ -81,15 +81,6 @@ unit:
python3 infra/test_playwright_summary.py
python3 infra/test_portal_caddyfiles.py
## docs: build the MkDocs site with --strict (a broken link or nav entry fails)
# Pinned in a throwaway venv: Material 9.7 is the last line on MkDocs 1.x, and MkDocs
# 2.0 drops the plugin/theme system this site relies on. Publishing is a separate
# decision (#173); this only proves the site builds.
docs:
python3 -m venv .venv-docs
.venv-docs/bin/pip install --quiet mkdocs==1.6.1 mkdocs-material==9.7.7
.venv-docs/bin/mkdocs build --strict
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Each service owns its config + break
@@ -222,11 +213,6 @@ verify-registerrecord:
verify-objecten-notifications:
bash infra/run-objecten-notifications-check.sh
## verify-clamav: assert clamd detects EICAR and passes a clean stream over INSTREAM (S-28),
## against the already-running stack.
verify-clamav:
bash infra/run-clamav-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -235,7 +221,6 @@ verify:
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-clamav-check.sh \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
@@ -14,8 +14,10 @@
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsError(); as error) {
<p utrecht-paragraph role="alert">{{ error }}</p>
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
<label utrecht-form-label for="diploma">Diploma</label>
@@ -1,6 +1,5 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client';
@@ -143,28 +142,6 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
// S-29 (#192): the domain refuses an infected or non-PDF file (422 + reason) or cannot scan it (503);
// the citizen is told which, so they know whether to pick another file or simply retry.
it.each([
[422, { reason: 'infected' }, /virus/i],
[422, { reason: 'not-a-pdf' }, /geen PDF/i],
[503, null, /tijdelijk/i],
])('explains a refused diploma upload (%i %o)', async (status, error, message) => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new HttpErrorResponse({ status, error }))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
expect((await screen.findByRole('alert')).textContent).toMatch(message);
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
@@ -1,6 +1,5 @@
import { Component, inject, type OnInit, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { BffApiV1Service, type CurrentRegistration, type Refusal, type SubmitAccepted } from 'api-client';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
@@ -32,8 +31,7 @@ export class RegistrationPage implements OnInit {
protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
/** Why the last upload failed, worded for the citizen; undefined while there is nothing to report. */
protected readonly provideDocumentsError = signal<string | undefined>(undefined);
protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
@@ -82,12 +80,12 @@ export class RegistrationPage implements OnInit {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsError.set(undefined);
this.provideDocumentsFailed.set(false);
let contentBase64: string;
try {
contentBase64 = await readAsBase64(file);
} catch {
this.provideDocumentsError.set(uploadFailure());
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
return;
}
@@ -103,8 +101,8 @@ export class RegistrationPage implements OnInit {
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: (err: unknown) => {
this.provideDocumentsError.set(uploadFailure(err));
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
@@ -131,20 +129,6 @@ export class RegistrationPage implements OnInit {
}
}
/** Word a failed upload for the citizen: the BFF says why a file was refused (422 + reason) or that
* the virus scanner was unreachable (503, S-29); anything else is a generic retry. */
function uploadFailure(err?: unknown): string {
if (err instanceof HttpErrorResponse && err.status === 422) {
return (err.error as Refusal | null)?.reason === 'infected'
? 'Er is een virus gevonden in dit bestand. Het is niet opgeslagen; lever een ander bestand aan.'
: 'Dit bestand is geen PDF. Lever uw diploma aan als PDF-bestand.';
}
if (err instanceof HttpErrorResponse && err.status === 503) {
return 'Uw bestand kan tijdelijk niet worden gecontroleerd. Probeer het later opnieuw.';
}
return 'Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.';
}
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
function readAsBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
@@ -1,108 +0,0 @@
# ADR-0035: The deployed stack is published through the existing labs Caddy
- **Status:** Accepted
- **Date:** 2026-09-25
- **Deciders:** Respellion engineering
- **Slice:** [#177](https://git.labs.respellion.tech/eho/register-referentie/issues/177) —
that issue proposed the opposite (an in-cluster Caddy edge); this ADR records why the
host-side option won. Implemented in #179, #180 and #181.
## Context
The stack deploys to a single-node Talos VM (ADR-0033, #175). Until now it was only usable
through five SSH port-forwards: the portals' OIDC flow uses PKCE, PKCE needs
`crypto.subtle`, and browsers expose that only in a **secure context**, meaning HTTPS or a
`localhost` origin. A NodePort on the VM's address is neither. We want a URL a demo
audience can simply open.
Three facts about where things run shape the answer:
- The Talos VM is a libvirt guest on a **Fedora hypervisor in the office**, behind NAT
with no public address. The only way in from outside is an existing reverse SSH tunnel
(`autossh-reverse-tunnel.service`) into an `openssh-server` container on the labs
server.
- The **labs server** (public IP) already runs Caddy for `*.labs.respellion.tech`, with
the wildcard certificate (DNS-01 via Cloudflare) and ports 80/443. Every other labs
service is published there (repo `Infra`, `infra/development/`).
- #177 proposed a Caddy **inside the cluster**, fed by a layer-4 forward on the host, so
that routing and certificates would be cluster state. That assumes the public IP is on
the hypervisor. It isn't: the hypervisor has no inbound path, and 80/443 on the labs
server are already taken by the labs Caddy.
## Decision
**Publish the portals and Keycloak through the existing labs Caddy. Carry the traffic to
the cluster over a second reverse SSH tunnel from the hypervisor.**
```
browser ─https─▶ labs Caddy ─▶ openssh-server:3014x/30180
─reverse SSH tunnel─▶ Fedora hypervisor ─▶ Talos NodePorts
```
- **Hostnames** under the existing wildcard: `big-register` (openbaar), `big-mijn`
(self-service), `big-behandel`, `big-beheer`, and `big-auth` (Keycloak, with `/admin*`
answered 404).
- **Tunnel:** `big-portals-tunnel.service` on the hypervisor (repo `Infra`)
reverse-forwards the five browser-facing NodePorts into `openssh-server`. It is
separate from the access tunnel on `:6667`, so a failed forward can't cut SSH access.
Caddy joins the `openssh_default` network to reach the tunnel ends.
- **Keycloak's issuer** is the public origin. The chart value `keycloakUrl` replaces
`host` + NodePort in one helper, `big.keycloakUrl`, which feeds both `KC_HOSTNAME` and
the portals' `config.json` authority, so the two cannot drift (ADR-0010). The deploy
workflow sets it from the `KEYCLOAK_URL` repository variable.
- **`KC_PROXY_HEADERS=xforwarded`:** `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` builds the token,
userinfo and certs URLs from the request. That request reaches Keycloak as plain HTTP,
so the URLs came out `http://` and browsers blocked them as mixed content. Trusting
Caddy's `X-Forwarded-Proto` keeps them HTTPS. In-cluster calls send no such header and
still use `keycloak:8080`.
- **Demo MFA (optional):** `demo.otpAutofill` (`OTP_AUTOFILL`) makes the `big-demo` theme
(`infra/keycloak/themes/big-demo`) Keycloak's default. Its script fills in and submits
the medewerker OTP from the fixture secret (ADR-0031), so the step is visibly enforced
without an authenticator. It is off by default.
### Alternatives considered
- **In-cluster Caddy edge (#177, PR #178).** It would keep routes and certificates in
cluster state. But it needs a public inbound path to the hypervisor that doesn't exist,
plus a second certificate authority beside the labs Caddy, which already holds the
wildcard. Closed unmerged.
- **Port-forward on the office router to the hypervisor.** This opens the office network
itself to the internet. Rejected.
- **Move the cluster to a host with a public IP.** It would remove the tunnel, but it's a
bigger change than publishing one demo. It remains the natural step if the stack
outgrows a lab VM.
- **Keep the SSH port-forwards.** Fine for one developer, but not something you can send
to someone.
## Consequences
**Positive**
- Real hostnames and HTTPS, so PKCE works in any browser with no client-side setup.
- No new certificate handling: the labs Caddy's wildcard covers the new hosts.
- The chart stays edge-agnostic. With `keycloakUrl` empty it renders exactly as before,
so compose, CI and the `localhost` workflow are untouched.
**Negative / costs**
- **Routing lives outside the cluster**, in the Infra repo's Caddyfile. That is exactly
what #177 wanted to avoid. Adding a portal means changing three places: a NodePort in
the chart, a forward in the tunnel unit, and a host in the Caddyfile.
- **Two SSH hops in the data path.** If the hypervisor or the tunnel is down, the
portals return 502 even though the cluster is healthy.
- **One issuer string.** With `keycloakUrl` set, the `localhost` port-forward workflow
(runbook §5) can no longer log in.
- **Keycloak trusts `X-Forwarded-*`** from anything that reaches it. Today that is only
in-cluster callers and the tunnel. `KC_PROXY_TRUSTED_ADDRESSES` can narrow it if the
NodePort is ever exposed more widely.
- **The portals are public.** Anyone with the link can log in with the committed test
credentials, and with `OTP_AUTOFILL` on, no second factor stands in the way. That is
acceptable for synthetic data. Put the labs Caddy's Azure `authorize` in front of the
`big-*` hosts if the audience must be restricted.
**Follow-up**
- Runbook: `docs/runbooks/kubernetes-talos.md`, "Publishing through the labs Caddy".
- Dev-mode Keycloak generates new signing keys on every restart, and the BFF re-fetches
them at most every 5 minutes, so expect a few minutes of 401s after a Keycloak restart.
Persisting Keycloak's database (runbook §6) would remove that.
@@ -1,56 +0,0 @@
# ADR-0036: Uploaded documents are scanned by ClamAV in the Domain Service, fail closed
- **Status:** Accepted
- **Date:** 2026-10-02
- **Deciders:** Respellion engineering
- **Slice:** proposed in [#190](https://git.labs.respellion.tech/eho/register-referentie/issues/190);
clamd deployed in [#191](https://git.labs.respellion.tech/eho/register-referentie/issues/191) (S-28),
scanning wired in [#192](https://git.labs.respellion.tech/eho/register-referentie/issues/192) (S-29).
## Context
A zorgprofessional's diploma upload goes portal → BFF → Domain (`ProvideDocuments`) →
ACL → OpenZaak. Nothing on that path looks at the file. It is not checked for malware,
and nobody checks that it is a PDF. Behandelaars open these files later, so the
register stores, and then serves, whatever a citizen sends.
Scanning needs a signature engine that stays up to date. That means a new peer service,
and that makes it an ADR (CLAUDE.md §14).
## Decision
1. **Engine:** the ClamAV daemon (`clamd`), official image `clamav/clamav`, pinned tag,
as its own service in compose and in the Helm chart. `freshclam` in the same container
keeps the signatures current, and they live on a volume.
2. **Where the check lives:** in the **Domain Service**, behind an `IDocumentScanner` port
in `Big.Application`. "Only a clean PDF is stored and unblocks beoordeling" is a rule
of the provide-documents use case. The BFF is a thin proxy (§8.3), and the ACL
translates ZGW and nothing else (§8.1). A check in the domain also covers every
entry point, not just the portal.
3. **Protocol:** the adapter in `Big.Infrastructure` speaks clamd's INSTREAM protocol over
`TcpClient`: `zINSTREAM\0`, length-prefixed chunks, a zero-length terminator, then a
`stream: OK` or `stream: <name> FOUND` reply. That is a few lines of code, so we add
**no NuGet package** for it (nClam and similar).
4. **Fail closed:** if clamd can't be reached, the upload is refused (503). Nothing is
stored and the document wait stays open. We never store an unscanned file.
5. **Type check:** content must also start with `%PDF-`, checked **after** the scan.
clamd matches EICAR (and many real signatures) only at the start of a file, so a type
check in front of the scan would report malware as merely "not a PDF". The check also
refuses a renamed non-PDF that is clean.
## Consequences
- One more long-running service. clamd holds its signatures in memory (about 1 GB idle).
`ConcurrentDatabaseReload no` stops a signature reload from holding a second copy,
but clamd pauses scans for the few seconds a reload takes. Compose caps it at
`mem_limit: 2g`, and the chart requests 1200Mi. This counts against the verify-stack
runner's memory ceiling (#182).
- The first start downloads about 300 MB of signatures from the ClamAV CDN, so the
runner and the cluster node need outbound internet (as `seed-zaaktype` already does).
The CDN rate-limits by IP. A CI runner that starts fresh often can get throttled, and
then the health check doesn't go green. If that happens, mirror the signatures
(`cvdupdate`) rather than retrying.
- Tests use the EICAR test string, built from two halves so the repo itself does not trip
an on-access scanner. No real malware is ever committed.
- Infected or non-PDF uploads are refused with 422 and a business message. We don't keep
a quarantine copy: a refused file is simply not stored.
-25
View File
@@ -878,28 +878,3 @@ Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
(ADR-0031).
---
## S-29 — Uploaded diplomas are virus-scanned (#192, ADR-0036)
**Outcome:** a diploma upload is stored only when it is a PDF that **ClamAV** scans clean. If the file
is infected, or not a PDF, the citizen is told why and the registration keeps waiting for a valid
diploma. If the scanner is down the upload is refused (fail closed) and the citizen is asked to retry.
```bash
# 1. Manual: submit a registration in the self-service portal, then upload as the diploma:
# - any real PDF → "Uw documenten zijn aangeleverd."
# - the EICAR test file (below) → "Er is een virus gevonden in dit bestand…"
# - a renamed .png → "Dit bestand is geen PDF…"
printf '%s%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$' 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.pdf
#
# 2. Automated: clamd itself (EICAR → FOUND, clean → OK) and the use case at every refusal:
make verify-clamav
dotnet test tests/acceptance --filter "FullyQualifiedName~EenDiplomaAanleveren"
```
**The path:** portal → BFF → Domain `ProvideDocuments`. The domain asks `IDocumentScanner`
(clamd over INSTREAM) first and checks `%PDF-` second, because clamd only spots EICAR at the start of
a file. Only a clean PDF goes on to the ACL and into ZGW. Refusals come back as 422 with a reason, a
scanner outage as 503 (ADR-0036).
+2 -6
View File
@@ -19,10 +19,9 @@ and CI cannot drift:
| `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
| `frontend` | `make frontend` → Nx lint/test/build for the four portals | pnpm + Node |
| `docs` | `make docs` → `mkdocs build --strict` in a pinned venv (fails on a broken link or nav entry; the site is not published yet, #173) | Python 3 |
| `k8s` | `make k8s-lint` (render + schema-check the Helm chart) → `make k8s-drift` (chart still describes the same stack as `infra/docker-compose.yml`) | pinned `helm` binary + `docker compose` |
| `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `verify-stack` | **push to `main` only, skipped on PRs** (#182) — the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
| `verify-stack` | the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
> **sequentially**, so booting OpenZaak once (instead of once per check) is the
@@ -58,12 +57,9 @@ dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in
make mutation # dotnet tool restore + dotnet stryker on the ACL
```
Config lives in `services/acl/stryker-config.json`.
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
`Acl.slnx` leaves out `Acl.IntegrationTests`: it needs a live OpenZaak, and Stryker
runs every test project in the solution, so keeping it in makes 8 tests fail in the
initial run (#174).
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
+50 -51
View File
@@ -356,7 +356,6 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`
| Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value |
| A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness |
| Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` |
| `clamav` not Ready for minutes | first start downloads ~300 MB of signatures, which needs outbound internet. A `429`/`cool-down` in `kubectl -n big logs deploy/clamav` means the ClamAV CDN is throttling this IP (ADR-0036) |
| `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` |
| Pods `Evicted` / `OOMKilled` | the VM is too small (§0) |
| A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` |
@@ -367,6 +366,36 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`
every time a PR is squash-merged to `main` (and on demand via *Run workflow*). PR CI is the
merge gate, so the workflow deploys without re-running the checks.
**Prerequisite: the VM must have been installed with the §1 patch.** A stock Talos config
gives you a node that still carries the control-plane taint and knows nothing about the
plain-HTTP registry, and the deploy hits those in that order: the `registry` pod sits
`Pending` until `rollout status` times out, and once that is fixed every repo image fails to
pull. Two separate fixes:
```bash
# on the Fedora host — 1. let workloads onto the only node (§1)
export KUBECONFIG=~/talos-kubeconfig-local
kubectl taint node --all node-role.kubernetes.io/control-plane-
# 2. trust the in-cluster registry over plain HTTP (§2)
cat > /tmp/registry-patch.yaml <<'YAML'
machine:
registries:
mirrors:
"<TALOS_VM_IP>:30500":
endpoints:
- http://<TALOS_VM_IP>:30500
YAML
talosctl -n <TALOS_VM_IP> -e <TALOS_VM_IP> patch mc --patch @/tmp/registry-patch.yaml
```
Keep those two apart. On Talos 1.14 a patch that also sets
`cluster.allowSchedulingOnControlPlanes` is rejected with *".cluster.allowSchedulingOnControlPlanes
is already set in v1alpha1 config"* — the field moved out of the v1alpha1 schema, the same way
`machine.install` did (§1) — and the rejection takes the whole patch with it, so the mirror
silently doesn't land either. `kubectl taint` is the documented way (§1); it is undone if the
node ever re-registers, which is a reboot, not a deploy.
The cluster's API and registry are not exposed publicly, so the job forwards them over the
same SSH hop the Gitea-runner pipeline uses:
@@ -398,60 +427,30 @@ Settings, all on the repository in Gitea:
The last step smokes `GET /openbaar/register` through the openbaar portal, which exercises
portal → Caddy → BFF → projection. An empty register passes; a 502 does not.
### Reaching the portals from a laptop
The deployed portals are pinned to `http://localhost:30180` for Keycloak (§5), so a browser
needs **all five** browser-facing ports on its own localhost — the portal alone is not
enough, and a missing Keycloak shows up as `ERR_CONNECTION_REFUSED` on
`/realms/*/.well-known/openid-configuration` followed by an opaque `ERROR Error: [object Object]`.
`make k8s-portals` does this when kubectl can reach the cluster; through the lab server one
SSH does it without a kubeconfig at all:
```bash
ssh -N -p 6667 \
-L 30140:<TALOS_VM_IP>:30140 -L 30141:<TALOS_VM_IP>:30141 \
-L 30142:<TALOS_VM_IP>:30142 -L 30143:<TALOS_VM_IP>:30143 \
-L 30180:<TALOS_VM_IP>:30180 \
user@labs.respellion.tech
```
Then the §5 table's URLs work as written. The admin UIs (OpenZaak, Flowable, …) need no
forward — they are server-rendered, so the VM's address is fine.
Not covered: the portals still need `make k8s-portals` (or an SSH forward) to be usable in a
browser, because PKCE needs a secure context (§5). Giving the server a hostname + TLS is the
upgrade path.
## Publishing through the labs Caddy
Why this route and not an in-cluster edge: [ADR-0035](../architecture/adr-0035-public-access-through-the-labs-caddy.md).
The portals can be reached on real hostnames through the Caddy that already fronts
`*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain:
```
browser → Caddy (labs server, TLS) → openssh-server:3014x/30180
→ reverse SSH tunnel → Fedora host → <TALOS_VM_IP>:3014x/30180 (NodePorts)
```
| URL | NodePort |
|---|---|
| `https://big-register.labs.respellion.tech` | 30141 openbaar |
| `https://big-mijn.labs.respellion.tech` | 30140 self-service |
| `https://big-behandel.labs.respellion.tech` | 30142 behandel |
| `https://big-beheer.labs.respellion.tech` | 30143 beheer |
| `https://big-auth.labs.respellion.tech` | 30180 Keycloak (`/admin` blocked) |
HTTPS makes the portals a secure context, so PKCE works without port-forwards — but
Keycloak's issuer must be the public origin. Deploy with it:
```bash
make k8s-up TALOS_HOST=localhost K8S_REGISTRY=<TALOS_HOST>:30500 \
K8S_SET="--set keycloakUrl=https://big-auth.labs.respellion.tech"
```
For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value.
With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string.
Staff logins still hit the enforced OTP step. For a demo, set the repository variable
`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): Keycloak then uses the `big-demo`
theme, which fills in and submits the code from the fixture secret, so the step is visible
but needs no authenticator. Keycloak restarts when the value flips. Demo only — the secret
is committed.
The theme lives in `infra/keycloak/themes/big-demo/` and is seeded as the `rr-kc-theme`
ConfigMap by `infra/helm/seed-configmaps.sh` on every deploy. Keycloak runs `start-dev`,
which doesn't cache themes, so an edit shows up about a minute after the ConfigMap changes.
A *new* theme file also needs a key in the seed script and a path in the keycloak `files`
in `values.yaml`.
One-time setup:
1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo
(instructions in the file).
2. Labs server: deploy the Infra `Caddyfile` + `compose.yml` (Caddy joins the
`openssh_default` network to reach the tunnel ends).
## What is not ported
- **Observability** (Tempo, Prometheus, Grafana) is defined but disabled — those are built
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""S-28 (#191): prove clamd is up, has signatures loaded, and scans a stream over INSTREAM.
The EICAR test file must come back FOUND and a clean payload OK — the same protocol the domain's
scanner adapter will speak (ADR-0036). EICAR is assembled from two halves so this file itself is
not flagged by an on-access scanner on a developer laptop. Stdlib only (python:3-slim).
"""
import os
import socket
import struct
import sys
import time
HOST = os.environ["CLAMAV"]
TIMEOUT = int(os.environ.get("CLAMAV_TIMEOUT", "60"))
EICAR = (r"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + r"EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*").encode()
def instream(payload):
with socket.create_connection((HOST, 3310), timeout=30) as s:
s.sendall(b"zINSTREAM\0" + struct.pack(">I", len(payload)) + payload + struct.pack(">I", 0))
return s.recv(4096).rstrip(b"\0").decode()
deadline = time.time() + TIMEOUT
while True:
try:
clean, infected = instream(b"%PDF-1.4 clean"), instream(EICAR)
break
except OSError as e:
if time.time() > deadline:
sys.exit(f"FAIL: clamd at {HOST}:3310 unreachable: {e}")
time.sleep(3)
print(f"clean → {clean!r}; eicar → {infected!r}")
if clean != "stream: OK":
sys.exit("FAIL: clean payload was not reported OK")
if not infected.endswith("FOUND"):
sys.exit("FAIL: EICAR was not detected")
print("OK: clamd detects EICAR and passes a clean stream")
-21
View File
@@ -751,26 +751,6 @@ services:
condition: service_completed_successfully
networks: [cg]
# ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM
# protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of
# signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory
# (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents
# that, at the cost of clamd pausing scans during a signature reload.
clamav:
image: docker.io/clamav/clamav:1.4.6
environment:
CLAMD_CONF_ConcurrentDatabaseReload: "no"
# The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so
# wait-healthy sees it as soon as the signatures are loaded.
healthcheck:
test: ["CMD-SHELL", "clamdcheck.sh"]
interval: 5s
start_period: 360s
mem_limit: 2g
volumes:
- clamav-db:/var/lib/clamav
networks: [cg]
volumes:
oz-db:
nrc-db:
@@ -778,7 +758,6 @@ volumes:
projection-db:
objecttypen-db:
objecten-db:
clamav-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
-26
View File
@@ -57,9 +57,6 @@ services:
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
# Same lever for oz-celery: unset, the worker forks one process per CPU (22 on the lab node,
# ~225 MB each), which OOM-killed the shared runner mid-verify-stack. Only celery reads it.
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -147,8 +144,6 @@ services:
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
# Two celery workers, not one per CPU — see the oz-env note above.
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -785,26 +780,6 @@ services:
condition: service_completed_successfully
networks: [cg]
# ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM
# protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of
# signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory
# (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents
# that, at the cost of clamd pausing scans during a signature reload.
clamav:
image: docker.io/clamav/clamav:1.4.6
environment:
CLAMD_CONF_ConcurrentDatabaseReload: "no"
# The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so
# wait-healthy sees it as soon as the signatures are loaded.
healthcheck:
test: ["CMD-SHELL", "clamdcheck.sh"]
interval: 5s
start_period: 360s
mem_limit: 2g
volumes:
- clamav-db:/var/lib/clamav
networks: [cg]
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service
@@ -856,7 +831,6 @@ volumes:
projection-db:
objecttypen-db:
objecten-db:
clamav-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
@@ -94,10 +94,6 @@ volumes:
{{- with .defaultMode }}
defaultMode: {{ . }}
{{- end }}
{{- with .items }}
items:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with $w.data }}
- name: data
@@ -129,26 +125,14 @@ volumes:
{{/*
Env list from a map. Every value is run through `tpl`, so values.yaml can name
cluster-internal hosts ({{ .Release.Namespace }}) and the node address
({{ .Values.host }}) without the chart hard-coding either. A value that renders
empty is left out, which is how a setting is made conditional on a chart value.
({{ .Values.host }}) without the chart hard-coding either.
*/}}
{{- define "big.env" -}}
{{- $root := index . 0 -}}
{{- range $k, $v := index . 1 }}
{{- $val := tpl (toString $v) $root }}
{{- if $val }}
- name: {{ $k }}
value: {{ $val | quote }}
value: {{ tpl (toString $v) $root | quote }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
The origin a browser reaches Keycloak on: the issuer Keycloak pins and the
authority the portals use, from one place so they cannot drift (ADR-0010).
*/}}
{{- define "big.keycloakUrl" -}}
{{- .Values.keycloakUrl | default (printf "http://%s:%v" .Values.host (index .Values.nodePorts "keycloak")) -}}
{{- end -}}
{{- define "big.labels" -}}
@@ -40,5 +40,5 @@ metadata:
{{- include "big.labels" (dict "root" $ "name" (printf "portal-config-%s" $realm)) | nindent 4 }}
data:
config.json: |
{ "authority": "{{ include "big.keycloakUrl" $ }}/realms/{{ $realm }}" }
{ "authority": "{{ printf "http://%s:%v" $.Values.host (index $.Values.nodePorts "keycloak") }}/realms/{{ $realm }}" }
{{- end }}
@@ -28,7 +28,7 @@ spec:
{{- range $w.files }}
{{- if hasPrefix "portal-config-" .configMap }}
annotations:
checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }}
checksum/portal-config: {{ printf "%s|%v" $.Values.host (index $.Values.nodePorts "keycloak") | sha256sum }}
{{- end }}
{{- end }}
labels:
+5 -61
View File
@@ -25,23 +25,6 @@
# string, so browser tokens and the BFF's discovered issuer agree.
host: 192.168.122.100
# Set when a TLS proxy outside the cluster publishes Keycloak: the full origin, no
# trailing slash. It replaces `host` + Keycloak's NodePort as the issuer and the
# portals' authority (runbook, "Publishing through the labs Caddy").
keycloakUrl: ""
# Where the .NET services send traces (OTLP gRPC). The default is the chart's own
# `tempo` workload (off by default, like compose). Point it at a Tempo outside the
# release, e.g. the cluster monitoring stack's http://tempo.monitoring.svc:4317 —
# with no Tempo at all, every export fails and is counted as a .NET exception.
otelEndpoint: http://tempo:4317
demo:
# Fill in and submit the medewerker OTP step from the fixture secret, so a public
# demo shows MFA enforced without an authenticator: makes the big-demo theme
# (infra/keycloak/themes/big-demo) Keycloak's default. Demo only: the secret is committed.
otpAutofill: false
# Set when pulling from a private registry (e.g. the Gitea Container Registry).
imagePullSecrets: []
@@ -88,7 +71,6 @@ envGroups:
oz:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: oz-db
@@ -111,7 +93,6 @@ envGroups:
nrc:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: nrc-db
@@ -166,11 +147,10 @@ envGroups:
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
# Traces for the .NET services. Always set, like compose. With no Tempo behind
# `otelEndpoint` the exporter fails quietly but throws on every batch, which
# shows up as HttpRequestException/SocketException in dotnet_exceptions_total.
# Traces for the .NET services. Always set, like compose: the exporter fails
# harmlessly when Tempo is absent (services/*/Program.cs).
otel:
OTEL_EXPORTER_OTLP_ENDPOINT: '{{ .Values.otelEndpoint }}'
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
# ── Workloads ──────────────────────────────────────────────────────────────────
@@ -288,31 +268,13 @@ workloads:
# Pin the issuer to the address the browser uses, and let backchannel calls
# keep using keycloak:8080 — the BFF discovers metadata in-cluster and gets
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
KC_HOSTNAME: "http://{{ .Values.host }}:{{ index .Values.nodePorts \"keycloak\" }}"
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
# Only rendered with demo.otpAutofill (big.env skips empty values); off, Keycloak
# keeps its stock theme and the mounted big-demo theme is unused.
KC_SPI_THEME_DEFAULT: '{{ if .Values.demo.otpAutofill }}big-demo{{ end }}'
# Behind a TLS proxy (keycloakUrl) the dynamic backchannel URLs — token,
# userinfo, certs — take their scheme from the request, which reaches Keycloak
# as plain http; trusting X-Forwarded-Proto keeps them https so the browser
# doesn't block them as mixed content. In-cluster calls send no such header.
KC_PROXY_HEADERS: xforwarded
ports: [{ name: http, port: 8080 }]
# TCP, not /health/ready on the management port: nothing here gates on realm
# import, and a wrong health path would leave the Service with no endpoints.
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
files:
- { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }
# infra/keycloak/themes/big-demo, seeded by infra/helm/seed-configmaps.sh.
- configMap: rr-kc-theme
mountPath: /opt/keycloak/themes/big-demo
items:
- { key: login.properties, path: login/theme.properties }
- { key: otp-autofill.js, path: login/resources/js/otp-autofill.js }
- { key: account.properties, path: account/theme.properties }
- { key: admin.properties, path: admin/theme.properties }
- { key: email.properties, path: email/theme.properties }
files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }]
# ── Flowable (S-03) ─────────────────────────────────────────────────────────
flowable-db:
@@ -588,24 +550,6 @@ workloads:
envFrom: [objecten]
waitFor: [objecten-db:5432, objecten-redis:6379]
# ── ClamAV (S-28, ADR-0036) ─────────────────────────────────────────────────
# The domain scans uploaded diplomas over clamd's INSTREAM protocol (S-29).
# First start pulls ~300 MB of signatures, so the node needs outbound internet
# (like seed-zaaktype); the data volume keeps them when persistence is on.
clamav:
image: docker.io/clamav/clamav:1.4.6
env:
CLAMD_CONF_ConcurrentDatabaseReload: "no"
ports: [{ name: clamd, port: 3310 }]
data: { mountPath: /var/lib/clamav, size: 1Gi }
probe:
exec: { command: [clamdcheck.sh] }
periodSeconds: 5
failureThreshold: 72
resources:
requests: { memory: 1200Mi }
limits: { memory: 2Gi }
# ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ──
# Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the
# server-assigned URLs are host-consistent. The ACL then resolves them by
-9
View File
@@ -30,15 +30,6 @@ seed() { # name <kubectl --from-file args...>
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
# The big-demo login theme (demo.otpAutofill). ConfigMap keys are flat, so each
# file gets a key here and its path back in the keycloak `files` in values.yaml.
theme="$repo/infra/keycloak/themes/big-demo"
seed rr-kc-theme \
--from-file=login.properties="$theme/login/theme.properties" \
--from-file=otp-autofill.js="$theme/login/resources/js/otp-autofill.js" \
--from-file=account.properties="$theme/account/theme.properties" \
--from-file=admin.properties="$theme/admin/theme.properties" \
--from-file=email.properties="$theme/email/theme.properties"
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
@@ -1,4 +0,0 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the account page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v3
@@ -1,4 +0,0 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the admin page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v2
@@ -1,4 +0,0 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the email page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak
@@ -1,24 +0,0 @@
// RFC 6238 with Keycloak's default policy (HmacSHA1, 6 digits, 30 s) over the
// raw bytes of the medewerker fixture secret — same as tests/e2e/keycloak-login.ts.
document.addEventListener('DOMContentLoaded', async () => {
const input = document.querySelector('input[name="otp"]');
if (!input || !input.form) return;
const key = await crypto.subtle.importKey('raw',
new TextEncoder().encode('BIGMEDEWERKEROTPSEED'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
// A code is single-use, so a second login in the same window spends the next
// counter (Keycloak's look-ahead accepts it). Past that, fill but don't submit,
// so a rejected code can't turn into a submit loop.
const now = Math.floor(Date.now() / 30000);
let last = -1;
try { last = Number(sessionStorage.getItem('big-otp-counter')) || -1; } catch {}
const counter = Math.max(now, last + 1);
const msg = new DataView(new ArrayBuffer(8));
msg.setBigUint64(0, BigInt(counter));
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg.buffer));
const o = mac[19] & 0x0f;
const n = ((mac[o] & 0x7f) << 24 | mac[o + 1] << 16 | mac[o + 2] << 8 | mac[o + 3]) % 1e6;
input.value = String(n).padStart(6, '0');
if (counter > now + 1) return;
try { sessionStorage.setItem('big-otp-counter', String(counter)); } catch {}
input.form.requestSubmit();
});
@@ -1,11 +0,0 @@
# Demo login theme for the public Talos deployment: keycloak.v2 plus a script that
# fills in and submits the medewerker OTP step from the committed fixture secret
# (docs/runbooks/keycloak.md). Only used when the chart's demo.otpAutofill is on —
# it then becomes Keycloak's default theme. Never enable it anywhere real.
#
# Add styles, messages or template overrides here as in any Keycloak theme
# (https://www.keycloak.org/ui-customization/themes); new files must also be
# listed in infra/helm/seed-configmaps.sh and the keycloak `files` in values.yaml.
parent=keycloak.v2
import=common/keycloak
scripts=js/otp-autofill.js
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
#
# S-28 (#191): assert clamd scans over INSTREAM (EICAR → FOUND, clean → OK), against an
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
# runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
av="$(docker ps -q --filter 'name=[-_]clamav[-_][0-9]+$' | head -1)"
[ -n "$av" ] || { echo "ERROR: no running clamav container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$av" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$av")"
echo ">> network=$net clamav=$ip"
cid="$(docker create --network "$net" -e "CLAMAV=$ip" -e "CLAMAV_TIMEOUT=${CLAMAV_TIMEOUT:-60}" \
python:3-slim python /clamav-check.py)"
docker cp "$here/clamav-check.py" "$cid:/clamav-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
@@ -59,10 +59,6 @@ export interface ProvideDocumentsRequest {
contentType?: string | null;
}
export interface Refusal {
reason: string;
}
export interface SubmitAccepted {
registrationId: string;
status: string;
-2
View File
@@ -56,8 +56,6 @@ nav:
- "ADR-0032: Werkbak live refresh": architecture/adr-0032-werkbak-live-refresh.md
- "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md
- "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md
- "ADR-0035: Public access through the labs Caddy": architecture/adr-0035-public-access-through-the-labs-caddy.md
- "ADR-0036: Scan uploads with ClamAV": architecture/adr-0036-scan-uploads-with-clamav.md
- FDS-architectuur:
- Overzicht: architecture/fds/README.md
- Componentview (L3): architecture/fds/c4-component-view.md
+1 -3
View File
@@ -1,9 +1,7 @@
<Solution>
<!-- Stryker-only. Acl.IntegrationTests is left out on purpose: it needs a live
OpenZaak, so in the mutation job it fails its initial run (#174). The root
register-referentie.slnx still builds and lints it. -->
<Project Path="Acl.Api/Acl.Api.csproj" />
<Project Path="Acl.Application/Acl.Application.csproj" />
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="Acl.Tests/Acl.Tests.csproj" />
</Solution>
+8 -23
View File
@@ -36,9 +36,9 @@ public interface IDomainClient
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
/// <summary>Provide (upload) the diploma the caller's own registration is waiting for ("documenten
/// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. The domain
/// refuses a non-PDF or infected file, and an upload it could not scan (S-29, ADR-0036).</summary>
Task<ProvideDocumentsResult> ProvideDocumentsAsync(
/// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. Returns
/// <c>false</c> when the domain reports the registration is unknown or not the caller's (404).</summary>
Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
@@ -48,12 +48,6 @@ public interface IDomainClient
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
}
/// <summary>How the domain answered a provide-documents request (S-29).</summary>
public enum ProvideDocumentsResult { Provided, NotFound, NotAPdf, Infected, ScannerUnavailable }
/// <summary>The body of a 422 provide-documents answer: why the file was refused.</summary>
public sealed record Refusal(string Reason);
/// <summary>Port to the read projection.</summary>
public interface IProjectionClient
{
@@ -122,26 +116,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return true;
}
public async Task<ProvideDocumentsResult> ProvideDocumentsAsync(
public async Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/documents",
new { bsn, contentBase64, fileName, contentType }, ct);
// The domain 404s an unknown or not-owned registration, 422s a refused file with its reason and
// 503s when its scanner is down (S-29); relay those rather than fail hard.
switch (response.StatusCode)
{
case System.Net.HttpStatusCode.NotFound:
return ProvideDocumentsResult.NotFound;
case System.Net.HttpStatusCode.ServiceUnavailable:
return ProvideDocumentsResult.ScannerUnavailable;
case System.Net.HttpStatusCode.UnprocessableEntity:
var refusal = await response.Content.ReadFromJsonAsync<Refusal>(ct);
return refusal?.Reason == "infected" ? ProvideDocumentsResult.Infected : ProvideDocumentsResult.NotAPdf;
}
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return ProvideDocumentsResult.Provided;
return true;
}
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
+4 -13
View File
@@ -166,8 +166,8 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
// registration that is unknown or not the caller's comes back 404. A non-PDF or infected file is 422
// with the reason; an unreachable scanner is 503 (S-29, ADR-0036).
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
// is S-10b — this is the trigger that unblocks the process.
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
@@ -177,22 +177,13 @@ app.MapPost("/self-service/registrations/{id}/documents", async (string id, Prov
return Results.BadRequest("A document is required.");
var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct);
return provided switch
{
ProvideDocumentsResult.Provided => Results.NoContent(),
ProvideDocumentsResult.NotAPdf => Results.UnprocessableEntity(new Refusal("not-a-pdf")),
ProvideDocumentsResult.Infected => Results.UnprocessableEntity(new Refusal("infected")),
ProvideDocumentsResult.ScannerUnavailable => Results.StatusCode(StatusCodes.Status503ServiceUnavailable),
_ => Results.NotFound(),
};
return provided ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces<Refusal>(StatusCodes.Status422UnprocessableEntity)
.Produces(StatusCodes.Status503ServiceUnavailable);
.Produces(StatusCodes.Status404NotFound);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
+5 -4
View File
@@ -111,13 +111,14 @@ internal sealed class FakeDomainClient : IDomainClient
public (string RegistrationId, string Bsn, string ContentBase64, string? FileName, string? ContentType)? DocumentsProvidedFor { get; private set; }
/// <summary>How the fake domain answers provide-documents. Tests set this to exercise the relay.</summary>
public ProvideDocumentsResult ProvideDocumentsResult { get; set; } = ProvideDocumentsResult.Provided;
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
public bool ProvideDocumentsSucceeds { get; set; } = true;
public Task<ProvideDocumentsResult> ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
{
DocumentsProvidedFor = (registrationId, bsn, contentBase64, fileName, contentType);
return Task.FromResult(ProvideDocumentsResult);
return Task.FromResult(ProvideDocumentsSucceeds);
}
public (string RegistrationId, string Besluit)? Decided { get; private set; }
@@ -1,32 +0,0 @@
using System.Net;
using System.Text;
using Bff.Api;
namespace Bff.Tests;
// S-29 (#192): the BFF reads the domain's provide-documents answer — 422 carries the refusal reason,
// 503 means the scanner was down — into a result the endpoint relays.
public class DomainClientProvideDocumentsTests
{
private sealed class Reply(HttpStatusCode status, string? json) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(json ?? "", Encoding.UTF8, "application/json"),
});
}
[Theory]
[InlineData(HttpStatusCode.NoContent, null, ProvideDocumentsResult.Provided)]
[InlineData(HttpStatusCode.NotFound, null, ProvideDocumentsResult.NotFound)]
[InlineData(HttpStatusCode.UnprocessableEntity, """{"reason":"not-a-pdf"}""", ProvideDocumentsResult.NotAPdf)]
[InlineData(HttpStatusCode.UnprocessableEntity, """{"reason":"infected"}""", ProvideDocumentsResult.Infected)]
[InlineData(HttpStatusCode.ServiceUnavailable, null, ProvideDocumentsResult.ScannerUnavailable)]
public async Task Maps_the_domain_answer_to_a_result(HttpStatusCode status, string? body, ProvideDocumentsResult expected)
{
var client = new DomainClient(new HttpClient(new Reply(status, body)) { BaseAddress = new Uri("http://domain/") });
Assert.Equal(expected, await client.ProvideDocumentsAsync("reg-1", "123456782", "JVBERi0=", null, null));
}
}
@@ -1,7 +1,6 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Bff.Api;
namespace Bff.Tests;
@@ -163,39 +162,13 @@ public class SelfServiceEndpointTests
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
{
using var factory = new BffFactory();
factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.NotFound;
factory.Domain.ProvideDocumentsSucceeds = false;
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
// S-29 (#192): the domain's refusal reaches the portal — 422 with the reason it words for the citizen.
[Theory]
[InlineData(ProvideDocumentsResult.NotAPdf, "not-a-pdf")]
[InlineData(ProvideDocumentsResult.Infected, "infected")]
public async Task Relays_a_refused_document_as_unprocessable_with_its_reason(ProvideDocumentsResult result, string reason)
{
using var factory = new BffFactory();
factory.Domain.ProvideDocumentsResult = result;
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
Assert.Equal(reason, (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("reason").GetString());
}
[Fact]
public async Task Relays_an_unavailable_scanner_as_service_unavailable()
{
using var factory = new BffFactory();
factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.ScannerUnavailable;
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
private static HttpRequestMessage Current(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
-24
View File
@@ -124,19 +124,6 @@
},
"404": {
"description": "Not Found"
},
"422": {
"description": "Unprocessable Entity",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Refusal"
}
}
}
},
"503": {
"description": "Service Unavailable"
}
}
}
@@ -428,17 +415,6 @@
}
}
},
"Refusal": {
"required": [
"reason"
],
"type": "object",
"properties": {
"reason": {
"type": "string"
}
}
},
"SubmitAccepted": {
"required": [
"registrationId",
+4 -16
View File
@@ -37,10 +37,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl").Get<AclOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl'"));
// clamd defaults to the compose/chart service name; ClamAv__* overrides it (S-29, ADR-0036).
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("ClamAv").Get<ClamAvOptions>() ?? new ClamAvOptions());
builder.Services.AddSingleton<IDocumentScanner, ClamdDocumentScanner>();
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
@@ -162,8 +158,8 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. Only a
// PDF that clamd scans clean is stored and unblocks the process (S-29).
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
@@ -181,16 +177,8 @@ app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsR
var command = new ProvideDocumentsCommand(
new RegistrationId(guid), body.Bsn, content,
body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf");
// A refused file is 422 with a machine-readable reason the portal words for the citizen; an
// unreachable scanner is 503 — retryable, and nothing was stored (S-29, ADR-0036).
return await provide.HandleAsync(command, ct) switch
{
ProvideDocumentsOutcome.Accepted => Results.NoContent(),
ProvideDocumentsOutcome.NotAPdf => Results.UnprocessableEntity(new { reason = "not-a-pdf" }),
ProvideDocumentsOutcome.Infected => Results.UnprocessableEntity(new { reason = "infected" }),
ProvideDocumentsOutcome.ScannerUnavailable => Results.StatusCode(StatusCodes.Status503ServiceUnavailable),
_ => Results.NotFound(),
};
var outcome = await provide.HandleAsync(command, ct);
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
-19
View File
@@ -136,22 +136,3 @@ public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
/// cancels the case (ADR-0017).
/// </summary>
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
/// <summary>What a malware scan of an uploaded document found (S-29, ADR-0036).</summary>
public enum ScanVerdict
{
Clean,
Infected,
/// <summary>The scanner could not be reached or did not answer — the upload is refused (fail closed).</summary>
Unavailable,
}
/// <summary>
/// The port to the malware scanner (S-29, ADR-0036). Implemented in Infrastructure over clamd's INSTREAM
/// protocol. Never throws for a scanner outage: an unreachable scanner is <see cref="ScanVerdict.Unavailable"/>.
/// </summary>
public interface IDocumentScanner
{
Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default);
}
@@ -18,26 +18,17 @@ public enum ProvideDocumentsOutcome
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
/// <summary>The file does not start with the PDF signature (<c>%PDF-</c>); nothing was stored.</summary>
NotAPdf,
/// <summary>The malware scan found something; nothing was stored and the wait stays open.</summary>
Infected,
/// <summary>The scanner could not be reached — refused rather than storing an unscanned file.</summary>
ScannerUnavailable,
}
/// <summary>
/// The provide-documents use case (S-10a/S-10b): a zorgprofessional uploads the diploma their
/// registration is parked waiting for. Only a PDF that scans clean is accepted (S-29, ADR-0036). The document is stored in ZGW via the ACL (§8.1), then the
/// registration is parked waiting for. The document is stored in ZGW via the ACL (§8.1), then the
/// WachtOpDocumenten task is completed so the registratie process leaves the 30-day wait and continues
/// to beoordeling (ADR-0017). Owner-scoped by bsn. Both steps are best-effort about missing preconditions
/// (mirroring <see cref="WithdrawRegistration"/>): storage needs an opened zaak, and completion needs a
/// running process — a request that arrives before either still stands, storing/completing what it can.
/// </summary>
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl, IDocumentScanner scanner)
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl)
{
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
{
@@ -49,18 +40,6 @@ public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient w
if (registration is null || registration.Bsn != command.Bsn)
return ProvideDocumentsOutcome.NotFound;
// Only a clean PDF goes any further (S-29, ADR-0036): checked after ownership, so a stranger
// learns nothing about the file, and before anything is stored or the wait is completed. Scan
// before the PDF check, so malware is reported as malware whatever it claims to be.
switch (await scanner.ScanAsync(command.Content, ct))
{
case ScanVerdict.Infected: return ProvideDocumentsOutcome.Infected;
case ScanVerdict.Unavailable: return ProvideDocumentsOutcome.ScannerUnavailable;
}
if (!command.Content.AsSpan().StartsWith("%PDF-"u8))
return ProvideDocumentsOutcome.NotAPdf;
// Store the diploma against the zaak (once it is opened) — the ACL is the only ZGW caller (§8.1).
if (registration.ZaakUrl is not null)
await acl.StoreDiplomaAsync(
@@ -1,48 +0,0 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
using Big.Application;
namespace Big.Infrastructure;
/// <summary>
/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036): <c>zINSTREAM\0</c>, the
/// document as one big-endian length-prefixed chunk, a zero-length terminator, then one reply —
/// <c>stream: OK</c> or <c>stream: &lt;signature&gt; FOUND</c>. Anything else (an ERROR reply, a refused
/// connection, a timeout) is <see cref="ScanVerdict.Unavailable"/>, so the caller fails closed.
/// </summary>
public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner
{
public async Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(content);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(options.Timeout);
string reply;
try
{
using var client = new TcpClient();
await client.ConnectAsync(options.Host, options.Port, timeout.Token);
var stream = client.GetStream();
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, content.Length);
await stream.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token);
await stream.WriteAsync(length, timeout.Token);
await stream.WriteAsync(content, timeout.Token);
await stream.WriteAsync(new byte[4], timeout.Token);
using var reader = new StreamReader(stream, Encoding.ASCII);
reply = (await reader.ReadToEndAsync(timeout.Token)).TrimEnd('\0', '\n');
}
catch (Exception e) when (e is SocketException or IOException
|| (e is OperationCanceledException && !ct.IsCancellationRequested))
{
return ScanVerdict.Unavailable;
}
if (reply == "stream: OK") return ScanVerdict.Clean;
return reply.EndsWith(" FOUND", StringComparison.Ordinal) ? ScanVerdict.Infected : ScanVerdict.Unavailable;
}
}
@@ -27,12 +27,3 @@ public sealed class AclOptions
{
public Uri BaseUrl { get; set; } = null!;
}
/// <summary>Where clamd listens (S-29, ADR-0036). <see cref="Timeout"/> bounds one whole scan; a scan that
/// takes longer counts as the scanner being unavailable.</summary>
public sealed class ClamAvOptions
{
public string Host { get; set; } = "clamav";
public int Port { get; set; } = 3310;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
@@ -1,117 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Big.Application;
using Big.Infrastructure;
namespace Big.Tests;
// S-29 (#192, ADR-0036): the clamd INSTREAM adapter, against a fake clamd on a loopback socket. The live
// engine (EICAR → FOUND) is verified by verify-clamav.
public class ClamdDocumentScannerTests
{
/// <summary>A one-shot fake clamd: reads one INSTREAM request to its zero-length terminator,
/// records it, and answers <paramref name="reply"/>.</summary>
private sealed class FakeClamd : IDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
public Task<byte[]> Received { get; }
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
/// <param name="reply">The answer, or null to accept the request and never answer (a hung clamd).</param>
public FakeClamd(string? reply)
{
_listener.Start();
Received = Serve(reply);
}
private async Task<byte[]> Serve(string? reply)
{
using var client = await _listener.AcceptTcpClientAsync();
var stream = client.GetStream();
var received = new MemoryStream();
var buffer = new byte[4096];
while (!EndsWithTerminator(received))
{
var n = await stream.ReadAsync(buffer);
if (n == 0) break;
received.Write(buffer, 0, n);
}
if (reply is null)
await Task.Delay(TimeSpan.FromSeconds(10)); // long past any test timeout
else
await stream.WriteAsync(System.Text.Encoding.ASCII.GetBytes(reply + "\0"));
return received.ToArray();
}
// The request is "zINSTREAM\0" + chunks + a 4-byte zero length; it is complete once it ends in it.
private static bool EndsWithTerminator(MemoryStream s)
=> s.Length > 14 && s.ToArray()[^4..].All(b => b == 0);
public void Dispose() => _listener.Stop();
}
private static ClamdDocumentScanner ScannerFor(int port) =>
new(new ClamAvOptions { Host = "127.0.0.1", Port = port, Timeout = TimeSpan.FromSeconds(5) });
[Fact]
public async Task Sends_the_document_as_one_length_prefixed_instream_chunk()
{
using var clamd = new FakeClamd("stream: OK");
await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]);
Assert.Equal("zINSTREAM\0"u8.ToArray().Concat(new byte[] { 0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0 }),
await clamd.Received.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Theory]
[InlineData("stream: OK", ScanVerdict.Clean)]
[InlineData("stream: Eicar-Test-Signature FOUND", ScanVerdict.Infected)]
[InlineData("INSTREAM size limit exceeded. ERROR", ScanVerdict.Unavailable)]
public async Task Maps_the_clamd_reply_to_a_verdict(string reply, ScanVerdict expected)
{
using var clamd = new FakeClamd(reply);
Assert.Equal(expected, await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]));
}
[Fact]
public async Task An_unreachable_clamd_is_unavailable_not_an_exception()
{
// Grab a free port, then close it, so nothing listens there.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
Assert.Equal(ScanVerdict.Unavailable, await ScannerFor(port).ScanAsync([1, 2, 3]));
}
[Fact]
public async Task A_clamd_that_never_answers_is_unavailable_after_the_timeout()
{
using var clamd = new FakeClamd(reply: null);
var scanner = new ClamdDocumentScanner(
new ClamAvOptions { Host = "127.0.0.1", Port = clamd.Port, Timeout = TimeSpan.FromMilliseconds(300) });
Assert.Equal(ScanVerdict.Unavailable, await scanner.ScanAsync([1, 2, 3]));
}
[Fact]
public async Task A_cancelled_request_is_cancelled_not_reported_unavailable()
{
// The caller giving up is not a scanner outage: it propagates instead of becoming a 503.
using var clamd = new FakeClamd(reply: null);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ScannerFor(clamd.Port).ScanAsync([1, 2, 3], cts.Token));
}
[Fact]
public async Task Rejects_null_content()
=> await Assert.ThrowsAsync<ArgumentNullException>(() => ScannerFor(1).ScanAsync(null!));
[Fact]
public void Defaults_to_the_clamav_service_on_the_clamd_port()
=> Assert.Equal(("clamav", 3310), (new ClamAvOptions().Host, new ClamAvOptions().Port));
}
-11
View File
@@ -146,14 +146,3 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
return Task.CompletedTask;
}
}
internal sealed class FakeDocumentScanner(ScanVerdict verdict = ScanVerdict.Clean) : IDocumentScanner
{
public byte[]? Scanned { get; private set; }
public Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
{
Scanned = content;
return Task.FromResult(verdict);
}
}
@@ -20,10 +20,8 @@ public class ProvideDocumentsTests
return registration;
}
private static readonly byte[] Pdf = "%PDF-1.4 diploma"u8.ToArray();
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn, byte[]? content = null) =>
new(id, bsn, content ?? Pdf, "diploma.pdf", "application/pdf");
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) =>
new(id, bsn, [1, 2, 3], "diploma.pdf", "application/pdf");
[Fact]
public async Task Providing_documents_stores_the_diploma_and_completes_the_wait()
@@ -33,13 +31,13 @@ public class ProvideDocumentsTests
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var acl = new FakeAclClient();
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
var handler = new ProvideDocuments(store, workflow, acl);
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
// Stored against the registration's zaak, carrying the uploaded bytes + file metadata.
Assert.Equal((Zaak, Pdf, "diploma.pdf", "application/pdf"), acl.StoredDiploma);
Assert.Equal((Zaak, new byte[] { 1, 2, 3 }, "diploma.pdf", "application/pdf"), acl.StoredDiploma);
// …and the wait is completed so beoordeling can proceed.
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
}
@@ -53,7 +51,7 @@ public class ProvideDocumentsTests
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var acl = new FakeAclClient();
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
var handler = new ProvideDocuments(store, workflow, acl);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
@@ -66,7 +64,7 @@ public class ProvideDocumentsTests
public async Task Providing_for_an_unknown_registration_is_not_found()
{
var store = new FakeRegistrationStore();
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner());
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient());
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
}
@@ -82,7 +80,7 @@ public class ProvideDocumentsTests
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var acl = new FakeAclClient();
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
var handler = new ProvideDocuments(store, workflow, acl);
var outcome = await handler.HandleAsync(Command(registration.Id));
@@ -91,92 +89,8 @@ public class ProvideDocumentsTests
Assert.Equal("proc-9", workflow.CompletedDocumentWaitFor);
}
// S-29 (#192, ADR-0036): only a clean PDF is stored and unblocks beoordeling.
[Theory]
[InlineData(ScanVerdict.Infected, ProvideDocumentsOutcome.Infected)]
[InlineData(ScanVerdict.Unavailable, ProvideDocumentsOutcome.ScannerUnavailable)]
public async Task A_document_that_does_not_scan_clean_is_refused_and_the_wait_stays_open(
ScanVerdict verdict, ProvideDocumentsOutcome expected)
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var acl = new FakeAclClient();
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner(verdict));
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(expected, outcome);
Assert.Null(acl.StoredDiploma);
Assert.Null(workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task A_clean_file_that_is_not_a_pdf_is_refused()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var acl = new FakeAclClient();
var handler = new ProvideDocuments(store, workflow, acl, new FakeDocumentScanner());
var outcome = await handler.HandleAsync(Command(registration.Id, content: "MZ not a pdf"u8.ToArray()));
Assert.Equal(ProvideDocumentsOutcome.NotAPdf, outcome);
Assert.Null(acl.StoredDiploma);
Assert.Null(workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task Malware_is_reported_as_infected_even_when_it_is_not_a_pdf()
{
// Scan first: clamd matches EICAR (and much real malware) only at the start of a file, so a file
// that fails the PDF check must still be scanned, and the citizen told it is infected.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(),
new FakeDocumentScanner(ScanVerdict.Infected));
var outcome = await handler.HandleAsync(Command(registration.Id, content: "X5O!P not a pdf"u8.ToArray()));
Assert.Equal(ProvideDocumentsOutcome.Infected, outcome);
}
[Fact]
public async Task A_clean_pdf_is_scanned_before_it_is_stored()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var scanner = new FakeDocumentScanner();
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), scanner);
await handler.HandleAsync(Command(registration.Id));
Assert.Equal(Pdf, scanner.Scanned);
}
[Fact]
public async Task A_different_bsn_learns_nothing_about_the_scan()
{
// Ownership is checked first: someone else's registration is NotFound even for an infected file.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var scanner = new FakeDocumentScanner(ScanVerdict.Infected);
var handler = new ProvideDocuments(store, new FakeWorkflowClient(), new FakeAclClient(), scanner);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(ProvideDocumentsOutcome.NotFound, outcome);
Assert.Null(scanner.Scanned);
}
[Fact]
public async Task Rejects_a_null_command()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient(), new FakeDocumentScanner()).HandleAsync(null!));
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient(), new FakeAclClient()).HandleAsync(null!));
}
@@ -1,36 +0,0 @@
# language: en
# Drives S-29 (#192, ADR-0036). A zorgprofessional uploads the diploma their registration waits for
# ("documenten aanleveren"). Only a clean PDF is stored against the zaak and unblocks beoordeling; an
# infected file, a non-PDF, or an unreachable scanner is refused and the registration keeps waiting.
# Exercised against in-memory ports; the live clamd scan is verified by verify-clamav.
Feature: Een diploma aanleveren
Als BIG-register wil ik alleen veilige PDF-diploma's opslaan
zodat een behandelaar nooit een besmet bestand opent.
Scenario: Een schoon PDF-diploma wordt opgeslagen
Given a registration waiting for documents
When the zorgprofessional uploads a clean PDF diploma
Then the upload is accepted
And the diploma is stored against the zaak
And the registration no longer waits for documents
Scenario: Een besmet diploma wordt geweigerd
Given a registration waiting for documents
When the zorgprofessional uploads a PDF that the scanner reports infected
Then the upload is refused as "Infected"
And no document is stored against the zaak
And the registration still waits for documents
Scenario: Een bestand dat geen PDF is wordt geweigerd
Given a registration waiting for documents
When the zorgprofessional uploads a file that is not a PDF
Then the upload is refused as "NotAPdf"
And no document is stored against the zaak
And the registration still waits for documents
Scenario: De virusscanner is niet bereikbaar
Given a registration waiting for documents
When the zorgprofessional uploads a PDF while the scanner is unavailable
Then the upload is refused as "ScannerUnavailable"
And no document is stored against the zaak
And the registration still waits for documents
@@ -1,68 +0,0 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenDiplomaAanleveren.feature</c> (S-29). Submits a registration, attaches
/// its zaak, then applies the ProvideDocuments use case with a scanner stand-in that returns the verdict
/// the scenario names; one instance per scenario.</summary>
[Binding]
[Scope(Feature = "Een diploma aanleveren")]
public sealed class EenDiplomaAanleverenSteps
{
private const string OwnerBsn = "123456782";
private static readonly byte[] Pdf = "%PDF-1.4 diploma"u8.ToArray();
private readonly InMemoryRegistrationStore _store = new();
private readonly InMemoryWorkflowClient _workflow = new();
private readonly InMemoryAclClient _acl = new();
private RegistrationId _id;
private ProvideDocumentsOutcome _outcome;
[Given("a registration waiting for documents")]
public async Task GivenARegistrationWaitingForDocuments()
{
_id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
var registration = (await _store.GetAsync(_id))!;
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
await _store.SaveAsync(registration);
}
[When("the zorgprofessional uploads a clean PDF diploma")]
public Task WhenCleanPdf() => Upload(Pdf, ScanVerdict.Clean);
[When("the zorgprofessional uploads a PDF that the scanner reports infected")]
public Task WhenInfected() => Upload(Pdf, ScanVerdict.Infected);
[When("the zorgprofessional uploads a file that is not a PDF")]
public Task WhenNotAPdf() => Upload("MZ not a pdf"u8.ToArray(), ScanVerdict.Clean);
[When("the zorgprofessional uploads a PDF while the scanner is unavailable")]
public Task WhenScannerUnavailable() => Upload(Pdf, ScanVerdict.Unavailable);
private async Task Upload(byte[] content, ScanVerdict verdict)
=> _outcome = await new ProvideDocuments(_store, _workflow, _acl, new InMemoryDocumentScanner(verdict))
.HandleAsync(new ProvideDocumentsCommand(_id, OwnerBsn, content, "diploma.pdf", "application/pdf"));
[Then("the upload is accepted")]
public void ThenAccepted() => Assert.Equal(ProvideDocumentsOutcome.Accepted, _outcome);
[Then("the upload is refused as \"(.*)\"")]
public void ThenRefusedAs(string expected) => Assert.Equal(expected, _outcome.ToString());
[Then("the diploma is stored against the zaak")]
public void ThenStored() => Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.StoredDiploma?.ZaakUrl);
[Then("no document is stored against the zaak")]
public void ThenNotStored() => Assert.Null(_acl.StoredDiploma);
[Then("the registration no longer waits for documents")]
public void ThenWaitCompleted()
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.CompletedDocumentWaitFor);
[Then("the registration still waits for documents")]
public void ThenStillWaiting() => Assert.Null(_workflow.CompletedDocumentWaitFor);
}
@@ -75,9 +75,9 @@ public sealed class CapturingDomainClient : IDomainClient
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<ProvideDocumentsResult> ProvideDocumentsAsync(
public Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
=> Task.FromResult(ProvideDocumentsResult.Provided);
=> Task.FromResult(true);
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
@@ -77,13 +77,6 @@ public sealed class InMemoryAclClient : IAclClient
}
}
/// <summary>A scanner stand-in that returns the verdict the scenario names (S-29) — the live clamd
/// INSTREAM scan is verified by verify-clamav.</summary>
public sealed class InMemoryDocumentScanner(ScanVerdict verdict) : IDocumentScanner
{
public Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default) => Task.FromResult(verdict);
}
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
public sealed class InMemoryUserTaskClient : IUserTaskClient