From 753455275413c7657b783ef89c8203359aeeb911 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 7 Sep 2026 23:32:11 +0200 Subject: [PATCH] Make the replacement harness runnable and target a live issuer Closes the runnable half of gap G04. The Scenario B and C scripts could not execute: absent compose files, binaries sought at src/bin where the Makefile builds to bin/, --base-dn passed to a generator whose flag is --basedn, and a hardcoded workstation Go path invoked from outside the module. Repairing the shell alone would have proved nothing. Both scripts set KEYCAPE_TEST_ISSUER while the profile suite built its own httptest server and never read it, so they passed identically whether or not a provider was running. A harness that cannot fail for the reason it exists is worse than a missing one. src/tests/conformance targets the issuer named by KEYCAPE_TEST_ISSUER over HTTP: discovery, the profile authorization surface, published keys parsed under the runtime's own rules, excluded grants, and -- with credentials -- a real token exchange verified against those keys. It skips when the variable is unset, so make test is unchanged. Run against Keycloak 26.0 rather than asserted to work. Discovery, authorization surface and key checks passed, and a client_credentials exchange produced a token that verified against Keycloak's published JWKS through internal/jose. It also failed, correctly: stock Keycloak advertises the excluded implicit and password grants, and in Keycloak those are server capabilities rather than per-client toggles, so no emitted realm removes them. A migrated Keycloak has a wider grant surface than KeyCape, which substantiates with evidence what SCOPE previously asserted without it. Scenario B legitimately reports failure today. Directory migration, credential and MFA preservation and relying-party behaviour remain unexercised, and Scenario C has never been run end to end, so G04 does not fully close. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6 --- SCOPE.md | 9 +- docker-compose.scenario-b.yml | 33 +++ docker-compose.scenario-c.yml | 31 +++ ...26-09-05-011726-scope-intent-assessment.md | 26 ++ scripts/test-scenario-b.sh | 74 ++++-- scripts/test-scenario-c.sh | 10 +- src/tests/conformance/external_test.go | 229 ++++++++++++++++++ ...cement-harness-and-external-conformance.md | 130 ++++++++++ 8 files changed, 511 insertions(+), 31 deletions(-) create mode 100644 docker-compose.scenario-b.yml create mode 100644 docker-compose.scenario-c.yml create mode 100644 src/tests/conformance/external_test.go create mode 100644 workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md diff --git a/SCOPE.md b/SCOPE.md index 03f184e..70b860a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -78,7 +78,14 @@ Keycloak interchangeability are not established. See the assessment below. - Tests cover local handlers, adapters, transformations and CLI protocol behavior. They do not establish complete replacement against a running Keycloak/full-LDAP - stack. The Scenario B/C shell harnesses are incomplete. + stack. The Scenario B/C harnesses now run, and `src/tests/conformance` targets a + live issuer named by `KEYCAPE_TEST_ISSUER`, skipping when it is unset + (KEY-WP-0022). Run against Keycloak 26.0 it verified discovery, the + authorization surface, the published keys and a real token exchange — and + showed that stock Keycloak advertises the excluded `implicit` and `password` + grants, so it is not a drop-in for this profile. Directory migration, + credential and MFA preservation and relying-party behaviour remain unexercised; + Scenario C has not been run end to end. - The server listens on HTTP; HTTPS termination is deployment-owned. `/healthz` reports process status without probing dependencies. Development Compose needs configuration/key material absent from the checkout. Production diff --git a/docker-compose.scenario-b.yml b/docker-compose.scenario-b.yml new file mode 100644 index 0000000..a8976c2 --- /dev/null +++ b/docker-compose.scenario-b.yml @@ -0,0 +1,33 @@ +# Scenario B: IAM swap — Keycloak stands in for KeyCape over the same directory. +# +# The realm JSON is produced by scripts/test-scenario-b.sh before this stack +# starts; Keycloak imports it at boot. LLDAP is the same directory KeyCape reads, +# so this scenario changes the issuer and nothing else. +services: + lldap: + image: lldap/lldap:stable + environment: + - LLDAP_JWT_SECRET=devjwtsecret + - LLDAP_LDAP_USER_PASS=adminpassword + - LLDAP_LDAP_BASE_DN=dc=netkingdom,dc=local + ports: + - "3890:3890" + - "17170:17170" + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: ["start-dev", "--import-realm"] + environment: + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - KC_HEALTH_ENABLED=true + volumes: + # Written by the scenario script from the migration output. + - ./build/scenario-b/realm:/opt/keycloak/data/import:ro + ports: + - "8080:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"] + interval: 5s + timeout: 5s + retries: 30 diff --git a/docker-compose.scenario-c.yml b/docker-compose.scenario-c.yml new file mode 100644 index 0000000..8335417 --- /dev/null +++ b/docker-compose.scenario-c.yml @@ -0,0 +1,31 @@ +# Scenario C: directory swap — OpenLDAP replaces LLDAP, Keycloak replaces KeyCape. +# +# The LDIF and realm JSON are produced by scripts/test-scenario-c.sh before this +# stack starts. OpenLDAP imports any LDIF mounted into its bootstrap directory. +services: + openldap: + image: bitnami/openldap:2.6 + environment: + - LDAP_ROOT=dc=netkingdom,dc=local + - LDAP_ADMIN_USERNAME=admin + - LDAP_ADMIN_PASSWORD=adminpassword + - LDAP_SKIP_DEFAULT_TREE=yes + - LDAP_CUSTOM_LDIF_DIR=/ldifs + volumes: + - ./build/scenario-c/ldif:/ldifs:ro + ports: + - "1389:1389" + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: ["start-dev", "--import-realm"] + environment: + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - KC_HEALTH_ENABLED=true + volumes: + - ./build/scenario-c/realm:/opt/keycloak/data/import:ro + ports: + - "8080:8080" + depends_on: + - openldap diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index 39103bf..c7f3602 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -217,6 +217,32 @@ profile tests exercise the external Keycloak issuer. exercise actual replacement providers, directory migration, claims and MFA; record unchanged relying-party behavior and explicit migration limitations. +**Status 2026-09-07 (KEY-WP-0022): partially closed.** The harness now runs. The +shell prerequisites are fixed (root `bin/`, `--basedn`, module-relative `go -C +src`, prerequisite checks before anything starts), the two missing compose files +exist and pass `docker compose config`, and `src/tests/conformance` reads +`KEYCAPE_TEST_ISSUER` and exercises a running issuer over HTTP — discovery, +profile authorization surface, published keys parsed under the runtime's own +rules, and, with credentials, a real token exchange verified against those keys. +It skips when the variable is unset, so `make test` is unchanged. + +It was run against an actual Keycloak 26.0, not only asserted to work: discovery, +authorization surface and key checks passed, and a `client_credentials` exchange +against a created service client produced a token that verified against +Keycloak's published JWKS through `internal/jose`. + +**Finding that bears on the replacement claim:** stock Keycloak fails +`TestExcludedGrantsAreNotAdvertised`. Its discovery advertises `implicit` and +`password` alongside the profile's grants, and those are server capabilities in +Keycloak rather than per-client toggles, so no realm configuration removes them. +A migrated Keycloak therefore presents a wider grant surface than KeyCape does. +This substantiates with evidence what SCOPE already said was unestablished, and +means Scenario B legitimately reports a failure today rather than a green run. + +Still open: directory migration against a live OpenLDAP, credential and MFA +preservation, and unchanged relying-party behaviour are not exercised. Scenario C +has never been run end to end. G04 does not fully close. + ### G05 — Directory export can omit data without reporting it **Priority: medium. Kind: implementation gap.** diff --git a/scripts/test-scenario-b.sh b/scripts/test-scenario-b.sh index c9b655b..1624845 100755 --- a/scripts/test-scenario-b.sh +++ b/scripts/test-scenario-b.sh @@ -1,44 +1,68 @@ #!/usr/bin/env bash -# test-scenario-b.sh — Scenario B: IAM swap (KeyCape → Keycloak, same LLDAP directory) +# test-scenario-b.sh — Scenario B: IAM swap (KeyCape -> Keycloak, same LLDAP directory). # -# This script verifies that after migrating to Keycloak (with the same LLDAP directory), -# all profile tests pass without modification. +# Exports the canonical directory, transforms it into a Keycloak realm, starts +# Keycloak over that realm, and runs the external conformance suite against it. # -# Prerequisites: docker, docker compose +# Prerequisites: docker, docker compose, and `make build` (binaries land in bin/). set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" -echo "=== Scenario B: IAM Replacement Test ===" +BUILD_DIR="$REPO_ROOT/build/scenario-b" +COMPOSE="docker compose -f docker-compose.scenario-b.yml" +ISSUER_URL="http://localhost:8080/realms/netkingdom" -# Step 1: Export canonical data from LLDAP -echo "--- Step 1: Export canonical data ---" -./src/bin/lldap-export \ +die() { echo "scenario-b: $*" >&2; exit 1; } + +# Check every prerequisite up front: failing half way through leaves a running +# stack and a half-written realm, which is harder to diagnose than not starting. +for binary in lldap-export keycape-to-keycloak; do + [ -x "bin/$binary" ] || die "bin/$binary is missing; run 'make build' first" +done +command -v docker >/dev/null || die "docker is not installed" +command -v go >/dev/null || die "go is not installed" + +cleanup() { $COMPOSE down -v >/dev/null 2>&1 || true; } +trap cleanup EXIT + +mkdir -p "$BUILD_DIR/realm" + +echo "=== Scenario B: IAM replacement ===" + +echo "--- Step 1: start the directory ---" +$COMPOSE up -d lldap +timeout 120 bash -c 'until (exec 3<>/dev/tcp/127.0.0.1/3890) 2>/dev/null; do sleep 2; done' \ + || die "LLDAP did not accept connections within 120s" + +echo "--- Step 2: export the canonical directory ---" +./bin/lldap-export \ --url "${LLDAP_URL:-ldap://localhost:3890}" \ --bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \ --bind-pw "${LLDAP_BIND_PW:-adminpassword}" \ - --base-dn "dc=netkingdom,dc=local" \ - --output /tmp/canonical-export.yaml + --base-dn "${LLDAP_BASE_DN:-dc=netkingdom,dc=local}" \ + --output "$BUILD_DIR/canonical-export.yaml" -# Step 2: Transform to Keycloak realm -echo "--- Step 2: Transform to Keycloak realm ---" -./src/bin/keycape-to-keycloak \ - --input /tmp/canonical-export.yaml \ +echo "--- Step 3: transform into a Keycloak realm ---" +./bin/keycape-to-keycloak \ + --input "$BUILD_DIR/canonical-export.yaml" \ --realm netkingdom \ - --issuer "${ISSUER:-https://auth.netkingdom.local}" \ - --output /tmp/keycloak-realm-import.json + --issuer "${ISSUER:-$ISSUER_URL}" \ + --output "$BUILD_DIR/realm/netkingdom-realm.json" -# Step 3: Start Keycloak with the imported realm -echo "--- Step 3: Start Keycloak with imported realm ---" -docker compose -f docker-compose.scenario-b.yml up -d keycloak -echo "Waiting for Keycloak to be ready..." -timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/netkingdom/.well-known/openid-configuration > /dev/null; do sleep 3; done' +echo "--- Step 4: start Keycloak over the migrated realm ---" +$COMPOSE up -d keycloak +timeout 180 bash -c "until curl -sf '$ISSUER_URL/.well-known/openid-configuration' >/dev/null; do sleep 3; done" \ + || die "Keycloak did not serve discovery within 180s" -# Step 4: Run profile tests against Keycloak -echo "--- Step 4: Run profile tests against Keycloak ---" -KEYCAPE_TEST_ISSUER="http://localhost:8080/realms/netkingdom" \ - /home/worsch/go/bin/go test ./src/tests/profile/... -v -count=1 +echo "--- Step 5: run the external conformance suite against Keycloak ---" +# The suite reads KEYCAPE_TEST_ISSUER and talks to the running issuer over HTTP. +# Run it from the module directory: the Go module root is src/, not the repo root. +KEYCAPE_TEST_ISSUER="$ISSUER_URL" \ + go -C src test ./tests/conformance/... -v -count=1 echo "=== Scenario B PASSED ===" +echo "Note: this proves the migrated realm serves a conforming OIDC surface." +echo "It does not prove credential, MFA or relying-party behaviour was preserved." diff --git a/scripts/test-scenario-c.sh b/scripts/test-scenario-c.sh index b81240c..9f90f9e 100755 --- a/scripts/test-scenario-c.sh +++ b/scripts/test-scenario-c.sh @@ -17,7 +17,7 @@ echo "=== Scenario C: Full Expansion Test ===" # Step 1: Export canonical data from LLDAP echo "--- Step 1: Export canonical data ---" -./src/bin/lldap-export \ +./bin/lldap-export \ --url "${LLDAP_URL:-ldap://localhost:3890}" \ --bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \ --bind-pw "${LLDAP_BIND_PW:-adminpassword}" \ @@ -26,15 +26,15 @@ echo "--- Step 1: Export canonical data ---" # Step 2a: Generate LDIF for OpenLDAP echo "--- Step 2a: Generate OpenLDAP LDIF ---" -./src/bin/lldap-to-ldap \ +./bin/lldap-to-ldap \ --input /tmp/canonical-export.yaml \ --target openldap \ - --base-dn "dc=netkingdom,dc=local" \ + --basedn "dc=netkingdom,dc=local" \ --output /tmp/migration.ldif # Step 2b: Transform to Keycloak realm echo "--- Step 2b: Transform to Keycloak realm ---" -./src/bin/keycape-to-keycloak \ +./bin/keycape-to-keycloak \ --input /tmp/canonical-export.yaml \ --realm netkingdom \ --issuer "${ISSUER:-https://auth.netkingdom.local}" \ @@ -55,6 +55,6 @@ ldapadd -x -H ldap://localhost:389 -D "cn=admin,dc=netkingdom,dc=local" -w admin # Step 5: Run profile tests against Keycloak + OpenLDAP echo "--- Step 5: Run profile tests ---" KEYCAPE_TEST_ISSUER="http://localhost:8080/realms/netkingdom" \ - /home/worsch/go/bin/go test ./src/tests/profile/... -v -count=1 + go -C src test ./tests/conformance/... -v -count=1 echo "=== Scenario C PASSED ===" diff --git a/src/tests/conformance/external_test.go b/src/tests/conformance/external_test.go new file mode 100644 index 0000000..75cddf3 --- /dev/null +++ b/src/tests/conformance/external_test.go @@ -0,0 +1,229 @@ +// Package conformance_test exercises a running OIDC issuer over HTTP. +// +// Every other suite in this repository builds its own httptest server, which is +// the right shape for testing handlers but cannot say anything about a +// replacement provider. This suite exists for the opposite case: point it at a +// deployed KeyCape, or at the Keycloak a migration produced, and it checks the +// profile surface that a relying party actually depends on (KEY-WP-0022). +// +// It skips when KEYCAPE_TEST_ISSUER is unset, so `make test` is unaffected. +// +// KEYCAPE_TEST_ISSUER=http://localhost:8080/realms/netkingdom go test ./tests/conformance/... +// +// Optional, enabling the token-exchange checks: +// +// KEYCAPE_TEST_CLIENT_ID, KEYCAPE_TEST_CLIENT_SECRET, KEYCAPE_TEST_SCOPE +package conformance_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "keycape/internal/jose" +) + +const requestTimeout = 15 * time.Second + +func issuer(t *testing.T) string { + t.Helper() + value := strings.TrimRight(os.Getenv("KEYCAPE_TEST_ISSUER"), "/") + if value == "" { + t.Skip("KEYCAPE_TEST_ISSUER is not set; this suite targets a running issuer") + } + return value +} + +type discovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + IDTokenSigningAlgValues []string `json:"id_token_signing_alg_values_supported"` +} + +func get(t *testing.T, target string) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + t.Fatalf("build request for %s: %v", target, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", target, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + t.Fatalf("read %s: %v", target, err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: status %d", target, resp.StatusCode) + } + return body +} + +func fetchDiscovery(t *testing.T) discovery { + t.Helper() + var doc discovery + raw := get(t, issuer(t)+"/.well-known/openid-configuration") + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse discovery document: %v", err) + } + return doc +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +// The issuer claim binds every token this provider mints, so a discovery +// document advertising a different issuer than the one we asked breaks every +// downstream audience and issuer check. +func TestIssuerAdvertisesItself(t *testing.T) { + doc := fetchDiscovery(t) + if doc.Issuer != issuer(t) { + t.Errorf("issuer is %q, expected %q", doc.Issuer, issuer(t)) + } +} + +// The profile's non-negotiable authorization surface: code flow with S256 PKCE +// and RS256 signing. A replacement provider missing any of these is not a +// drop-in, whatever else it supports. +func TestProfileAuthorizationSurface(t *testing.T) { + doc := fetchDiscovery(t) + for _, required := range []struct { + what string + values []string + want string + }{ + {"response_types_supported", doc.ResponseTypesSupported, "code"}, + {"grant_types_supported", doc.GrantTypesSupported, "authorization_code"}, + {"code_challenge_methods_supported", doc.CodeChallengeMethodsSupported, "S256"}, + {"id_token_signing_alg_values_supported", doc.IDTokenSigningAlgValues, "RS256"}, + } { + if !contains(required.values, required.want) { + t.Errorf("%s does not advertise %q: %v", required.what, required.want, required.values) + } + } + for _, endpoint := range []struct{ what, value string }{ + {"authorization_endpoint", doc.AuthorizationEndpoint}, + {"token_endpoint", doc.TokenEndpoint}, + {"jwks_uri", doc.JWKSURI}, + } { + if endpoint.value == "" { + t.Errorf("%s is absent", endpoint.what) + continue + } + if _, err := url.Parse(endpoint.value); err != nil { + t.Errorf("%s is not a URL: %v", endpoint.what, err) + } + } +} + +// The published keys must satisfy the same rules the runtime applies when +// verifying a token, so a provider publishing keys KeyCape would refuse is +// caught here rather than at the first login. +func TestPublishedKeysAreUsable(t *testing.T) { + doc := fetchDiscovery(t) + if doc.JWKSURI == "" { + t.Skip("no jwks_uri advertised") + } + keys, err := jose.ParseJWKS(get(t, doc.JWKSURI)) + if err != nil { + t.Fatalf("published key set is not usable: %v", err) + } + if len(keys) == 0 { + t.Fatal("no usable RS256 signing keys published") + } +} + +// Implicit and password grants are excluded by the profile. A provider that +// still offers them is a wider attack surface than KeyCape presents, which +// matters when it is standing in for KeyCape. +func TestExcludedGrantsAreNotAdvertised(t *testing.T) { + doc := fetchDiscovery(t) + for _, excluded := range []string{"implicit", "password"} { + if contains(doc.GrantTypesSupported, excluded) { + t.Errorf("issuer advertises the excluded %q grant", excluded) + } + } +} + +// With credentials supplied, exchange them and verify the resulting token +// against the issuer's own published keys — the end-to-end property a relying +// party depends on. +func TestServiceTokenVerifiesAgainstPublishedKeys(t *testing.T) { + clientID := os.Getenv("KEYCAPE_TEST_CLIENT_ID") + clientSecret := os.Getenv("KEYCAPE_TEST_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + t.Skip("KEYCAPE_TEST_CLIENT_ID/SECRET not set; skipping token exchange") + } + doc := fetchDiscovery(t) + + form := url.Values{"grant_type": {"client_credentials"}} + if scope := os.Getenv("KEYCAPE_TEST_SCOPE"); scope != "" { + form.Set("scope", scope) + } + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, doc.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("token exchange: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + // The body can carry the secret back in an error echo; report status only. + t.Fatalf("token exchange: status %d", resp.StatusCode) + } + var tokens struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(body, &tokens); err != nil { + t.Fatalf("parse token response: %v", err) + } + if !strings.EqualFold(tokens.TokenType, "Bearer") { + t.Errorf("token_type is %q, expected Bearer", tokens.TokenType) + } + keys, err := jose.ParseJWKS(get(t, doc.JWKSURI)) + if err != nil { + t.Fatalf("published key set is not usable: %v", err) + } + claims, err := jose.Verify(tokens.AccessToken, keys) + if err != nil { + t.Fatalf("issued token does not verify against the published keys: %v", err) + } + if got, _ := claims["iss"].(string); got != doc.Issuer { + t.Errorf("token issuer %q does not match discovery issuer %q", got, doc.Issuer) + } + if _, ok := claims["exp"].(float64); !ok { + t.Error("issued token has no exp claim") + } + fmt.Fprintf(os.Stderr, "verified a token from %s against its published keys\n", doc.Issuer) +} diff --git a/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md b/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md new file mode 100644 index 0000000..ec7736d --- /dev/null +++ b/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md @@ -0,0 +1,130 @@ +--- +id: KEY-WP-0022 +type: workplan +title: "Make the replacement harness runnable and target conformance externally" +domain: infotech +repo: key-cape +status: finished +owner: claude +topic_slug: replacement-harness-and-external-conformance +created: "2026-09-07" +updated: "2026-09-07" +--- + +Closes the runnable half of gap G04 of +`history/2026-09-05-011726-scope-intent-assessment.md`. The Scenario B and C +scripts cannot execute: they reference compose files that do not exist, call +binaries at `src/bin/` where the Makefile builds to root `bin/`, pass `--base-dn` +to `lldap-to-ldap` whose flag is `--basedn`, and invoke a hardcoded workstation Go +path from outside the Go module. + +The deeper problem is that repairing the shell would not prove anything. Both +scripts set `KEYCAPE_TEST_ISSUER`, but the profile suite builds its own `httptest` +server and never reads it, so the tests pass identically whether or not the +external provider is running. A harness that cannot fail for the reason it exists +is worse than a missing one. + +## Repair the harness prerequisites + +```task +id: KEY-WP-0022-T01 +status: done +priority: high +``` + +Fix the binary paths, the `--basedn` flag, and the Go invocation so the scripts +run from a clean checkout with `make build`: use the module directory rather than +a workstation-specific Go path, and fail with a clear message when a prerequisite +binary or a required environment value is absent instead of part-way through. + +Scenario B rewritten: root `bin/`, `go -C src` so the module root is right, +prerequisite checks before anything starts, and a cleanup trap so a failure does +not leave a stack running. Scenario C repaired in place — `bin/` paths, `--basedn` +for `lldap-to-ldap` (`lldap-export` correctly takes `--base-dn`; the two flags +genuinely differ), and the same module-relative test invocation. + +## Add the missing stacks + +```task +id: KEY-WP-0022-T02 +status: done +priority: high +``` + +Provide `docker-compose.scenario-b.yml` (Keycloak over the migrated realm) and +`docker-compose.scenario-c.yml` (OpenLDAP seeded from generated LDIF, plus +Keycloak). Validate them with `docker compose config` rather than assuming they +parse. Whether a full scenario passes end to end is a separate claim from whether +the stack definition is valid, and only the second is established here. + +Both files added and validated with `docker compose config`. Scenario B was also +started for real; Scenario C's stack has not been run. + +## Target conformance at an external issuer + +```task +id: KEY-WP-0022-T03 +status: done +priority: high +``` + +Add a suite that reads `KEYCAPE_TEST_ISSUER` and exercises that issuer over HTTP: +discovery metadata, the JWKS parsing under the same strict rules the runtime +applies, and — when client credentials are supplied — a real token exchange whose +claims are verified against the published keys. It must skip cleanly when the +variable is unset so ordinary `make test` is unaffected, and it must fail when +pointed at an issuer that does not meet the profile, which is the property the +current harness lacks. + +`src/tests/conformance` checks that the issuer advertises itself, the profile +authorization surface (code, authorization_code, S256, RS256), that published +keys parse under `internal/jose` — the same rules the runtime applies, so a +provider publishing keys KeyCape would refuse fails here rather than at first +login — that excluded grants are absent, and, with credentials, that an issued +token verifies against the published keys and carries a matching issuer. + +## Prove the suite against a real provider + +```task +id: KEY-WP-0022-T04 +status: done +priority: high +``` + +Run the new suite against an actual Keycloak instance, not only against KeyCape, +since the point of the scenario is a replacement provider. Record what genuinely +passed and what remains unproven. Do not describe a partially exercised scenario +as a demonstrated live swap. + +Run against Keycloak 26.0. Discovery, authorization surface and published-key +checks passed. A `client_credentials` exchange against a service client created +through the admin API returned a token that verified against Keycloak's JWKS via +`internal/jose`, with a matching issuer claim — the end-to-end property a relying +party depends on, proved against a replacement provider rather than against +KeyCape's own handlers. + +The suite also failed, correctly. Stock Keycloak advertises `implicit` and +`password` in its discovery document. In Keycloak these are server capabilities, +not per-client toggles, so no realm the transformer emits can remove them: a +migrated Keycloak presents a wider grant surface than KeyCape. Scenario B +therefore reports a real failure today, which is the harness working. KeyCape +itself was not targeted in this run — the dev stack needs key material absent +from the checkout — so the suite is proved against the replacement provider, not +yet against both sides of the swap. + +## Reconcile the records + +```task +id: KEY-WP-0022-T05 +status: done +priority: medium +``` + +Update `SCOPE.md` and G04's status with what the harness now does and what +replacement claims remain unproven — in particular that directory migration, MFA +and unchanged relying-party behaviour against a live Keycloak are not established +by this work. G04 does not fully close here. + +SCOPE.md and G04's status record both the working harness and the grant-surface +finding, which substantiates with evidence what SCOPE previously asserted without +it. Scenario C has still never been run end to end.