feat(bff): BFF with one endpoint per portal + OIDC validation (closes #8) #64

Merged
not merged 7 commits from feat/8-bff into main 2026-07-01 09:32:45 +00:00
5 changed files with 106 additions and 3 deletions
Showing only changes of commit a07d8277d6 - Show all commits

View File

@@ -79,6 +79,13 @@ jobs:
name: domain-mutation-report name: domain-mutation-report
path: services/domain/StrykerOutput/**/reports/mutation-report.html path: services/domain/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: bff-mutation-report
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. On the single self-hosted # One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job) # runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
@@ -101,6 +108,8 @@ jobs:
run: make verify-projection run: make verify-projection
- name: Domain → Flowable → ACL → OpenZaak - name: Domain → Flowable → ACL → OpenZaak
run: make verify-domain run: make verify-domain
- name: BFF → Keycloak + domain + projection
run: make verify-bff
# Log dump must precede teardown (which removes the containers). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: failure() if: failure()

View File

@@ -64,14 +64,14 @@ unit:
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) ## 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` # 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 # makes `make mutation` work from a fresh clone. Each service owns its config + break
# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json, # threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json.
# Scores never regress below baseline. # Scores never regress below baseline.
mutation: mutation:
dotnet tool restore dotnet tool restore
cd services/acl && dotnet stryker cd services/acl && dotnet stryker
cd services/event-subscriber && dotnet stryker cd services/event-subscriber && dotnet stryker
cd services/domain && dotnet stryker cd services/domain && dotnet stryker
cd services/bff && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down ## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim; # SEED populates the external config volumes first (upstream images used verbatim;
@@ -143,6 +143,11 @@ verify-projection:
verify-domain: verify-domain:
bash infra/run-domain-check.sh bash infra/run-domain-check.sh
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
## + anonymous public-safe openbaar register (ADR-0010).
verify-bff:
bash infra/run-bff-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## 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` ## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead. ## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -154,7 +159,8 @@ verify:
&& bash infra/run-acl-integration.sh \ && bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \ && bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \ && bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh || rc=$$?; \ && bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \ docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc' exit $$rc'

View File

@@ -344,6 +344,13 @@ services:
context: ../services/bff context: ../services/bff
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/bff:dev image: register-referentie/bff:dev
environment:
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:
- "8080:8080" - "8080:8080"
healthcheck: healthcheck:
@@ -352,6 +359,13 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
start_period: 10s start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg] networks: [cg]
# ── Read projection (S-06) ──────────────────────────────────────────────── # ── Read projection (S-06) ────────────────────────────────────────────────

58
infra/run-bff-check.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# Verify the BFF end-to-end (S-07) against an ALREADY-RUNNING full stack. The BFF is the portals'
# only backend (§8.3): it validates Keycloak digid tokens on the self-service submit and serves the
# openbaar register anonymously with only public-safe fields (ADR-0010).
#
# Checks, in-network (services reached by container IP; Keycloak by its service name so the token's
# host-derived issuer matches the BFF's authority — see the compose bff env and ADR-0010):
# 1. POST /self-service/registrations without a token -> 401
# 2. mint a real digid access token (direct grant) and POST it -> 202 (forwarded to the domain)
# 3. GET /openbaar/register (anonymous) -> 200 JSON array, never a bsn
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown). Plain docker primitives.
set -euo pipefail
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
kc="$(docker ps -q --filter 'name=keycloak' | head -1)"
[ -n "$bff" ] || { echo "ERROR: no running bff container — bring the stack up first" >&2; exit 1; }
[ -n "$kc" ] || { echo "ERROR: no running keycloak container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"
echo ">> bff=$bff_ip network=$net"
# Helper: run curl inside a throwaway container on the stack network (reaches services by name/IP).
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
echo ">> 1. self-service submit without a token must be 401"
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
-H 'Content-Type: application/json' -d '{}')"
echo " -> $code"; [ "$code" = "401" ] || { echo "FAIL: expected 401, got $code" >&2; exit 1; }
echo ">> 2. minting a digid token (direct grant) via keycloak:8080 (host-consistent issuer)"
token=""
for _ in $(seq 1 20); do
token="$(net_curl -s -X POST "http://keycloak:8080/realms/digid/protocol/openid-connect/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' --data-urlencode 'client_id=big-portal' \
--data-urlencode 'username=jan-burger' --data-urlencode 'password=test123' \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
[ -n "$token" ] && break
sleep 3
done
[ -n "$token" ] || { echo "FAIL: could not obtain a digid access token" >&2; exit 1; }
echo " -> got a token"
echo ">> 2b. self-service submit with the token must be 202"
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}')"
echo " -> $code"; [ "$code" = "202" ] || { echo "FAIL: expected 202, got $code" >&2; docker logs "$bff" 2>&1 | tail -15 >&2; exit 1; }
echo ">> 3. openbaar register (anonymous) must be 200 JSON, never a bsn"
body="$(net_curl -s "http://$bff_ip:8080/openbaar/register")"
echo "$body" | grep -q '^\[' || { echo "FAIL: openbaar did not return a JSON array: $body" >&2; exit 1; }
if echo "$body" | grep -q '"bsn"'; then echo "FAIL: openbaar leaked a bsn field" >&2; exit 1; fi
echo "OK — BFF: 401 without token, 202 with a digid token, anonymous public-safe openbaar register"

View File

@@ -0,0 +1,16 @@
{
"stryker-config": {
"solution": "Bff.slnx",
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
"reporters": ["progress", "html"],
"mutate": [
"!**/Program.cs",
"!**/DownstreamClients.cs"
],
"thresholds": {
"high": 95,
"low": 90,
"break": 90
}
}
}