diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f857747..8501935 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -79,6 +79,13 @@ jobs: name: domain-mutation-report path: services/domain/StrykerOutput/**/reports/mutation-report.html 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 # runner jobs run sequentially, so booting OpenZaak once (instead of once per job) @@ -101,6 +108,8 @@ jobs: run: make verify-projection - name: Domain → Flowable → ACL → OpenZaak run: make verify-domain + - name: BFF → Keycloak + domain + projection + run: make verify-bff # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() diff --git a/Makefile b/Makefile index b6fba46..55879a5 100644 --- a/Makefile +++ b/Makefile @@ -64,14 +64,14 @@ unit: ## 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 -# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json, -# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json. +# threshold (the ratchet, CLAUDE.md §5): each services//stryker-config.json. # Scores never regress below baseline. mutation: dotnet tool restore cd services/acl && dotnet stryker cd services/event-subscriber && 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 # SEED populates the external config volumes first (upstream images used verbatim; @@ -143,6 +143,11 @@ verify-projection: verify-domain: 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, ## tear down (always). For fast single-concern local iteration use `integration` ## (oz-only) or `verify-notifications` (oz+nrc) instead. @@ -154,7 +159,8 @@ verify: && bash infra/run-acl-integration.sh \ && bash infra/run-notification-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 volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ exit $$rc' diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 02a4e90..0c10982 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -344,6 +344,13 @@ services: context: ../services/bff dockerfile: Dockerfile 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: - "8080:8080" healthcheck: @@ -352,6 +359,13 @@ services: timeout: 3s retries: 5 start_period: 10s + depends_on: + domain: + condition: service_healthy + projection-api: + condition: service_healthy + keycloak: + condition: service_started networks: [cg] # ── Read projection (S-06) ──────────────────────────────────────────────── diff --git a/infra/run-bff-check.sh b/infra/run-bff-check.sh new file mode 100755 index 0000000..3114699 --- /dev/null +++ b/infra/run-bff-check.sh @@ -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" diff --git a/services/bff/stryker-config.json b/services/bff/stryker-config.json new file mode 100644 index 0000000..fc3fe3c --- /dev/null +++ b/services/bff/stryker-config.json @@ -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 + } + } +}