From f3451e023a4e7bb6ffc2a05decbe913cf7b98d6c Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 19 May 2026 23:09:26 +0200 Subject: [PATCH 1/2] feat(identity): emit X-Client-ID header on every outbound request (v9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every governed request now carries an X-Client-ID header alongside the existing Basic Auth + X-Axonflow-Client headers, matching the v9 identity contract on the platform (Epic getaxonflow/axonflow-enterprise#2230). The header value mirrors the SDK's Basic Auth username — smart default "community" when no clientId is configured. The agent's apiAuthMiddleware overwrites the header with its auth-derived value, so caller-supplied values are harmless (no spoofing surface). Backward-compatible with v8 agents (they ignore the unknown header). Bumps to v8.1.0. Tests in XClientIdHeaderTest.java assert header presence + value across community + configured-client paths, and pin that the SDK does NOT send legacy X-Tenant-ID. Signed-off-by: Saurabh Jain --- CHANGELOG.md | 23 ++++ pom.xml | 2 +- .../java/com/getaxonflow/sdk/AxonFlow.java | 5 + .../getaxonflow/sdk/XClientIdHeaderTest.java | 102 ++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/getaxonflow/sdk/XClientIdHeaderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0ce94..8eadeb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.1.0] - 2026-05-19 — `X-Client-ID` header on every outbound request (v9 identity) + +**Companion release to the v9 identity cleanup on the platform (Epic #2230).** +Every governed request now carries an `X-Client-ID: ` +header alongside the existing Basic Auth + `X-Axonflow-Client` headers. +Value matches the SDK's Basic Auth username — smart default `community` +when no `clientId` is configured. + +### Added + +- **`X-Client-ID` header on outbound HTTP requests.** Server-side identity + decisions no longer need to re-decode Basic Auth. The agent's + `apiAuthMiddleware` overwrites the header with its own auth-derived + value, so caller-supplied values are harmless (no spoofing surface). + Set in `addAuthHeaders` (`AxonFlow.java`), the canonical funnel for + every governed request. + +### Compatibility + +- Backward-compatible against v8 and v9 platforms: v8 agents ignore the + unknown header; v9 agents derive identity from Basic Auth regardless. +- No SDK config changes. No removed fields. No changed defaults. + ## [8.0.0] - 2026-05-09 — Decision History API + policy_version recorded on every decision + telemetry simplification **Major release.** The headline feature is the new decision-history client diff --git a/pom.xml b/pom.xml index b4cc631..ea09fee 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 8.0.0 + 8.1.0 jar AxonFlow Java SDK diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index d85dec9..ae367f8 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -3907,6 +3907,11 @@ private void addAuthHeaders(Request.Builder builder) { // so the agent can derive request scope (sdk) and validate it against the // token's aud.scope via HasScope(). Sourced from SDK_VERSION; no env override. builder.header("X-Axonflow-Client", config.getClientHeader()); + // X-Client-ID (v9): server-side identity decisions don't have to + // re-decode Basic auth. The agent's apiAuthMiddleware overwrites + // the header with its auth-derived value, so caller-supplied + // values are harmless (no spoofing surface). + builder.header("X-Client-ID", effectiveClientId); } /** diff --git a/src/test/java/com/getaxonflow/sdk/XClientIdHeaderTest.java b/src/test/java/com/getaxonflow/sdk/XClientIdHeaderTest.java new file mode 100644 index 0000000..0534732 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/XClientIdHeaderTest.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package com.getaxonflow.sdk; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; + +import com.getaxonflow.sdk.types.*; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * X-Client-ID header verification (v9 identity). + * + *

Every governed request carries {@code X-Client-ID} alongside Basic Auth. The agent's + * apiAuthMiddleware overwrites the header with its own auth-derived value, so a missing or + * wrong client-side header is harmless server-side. These tests pin SDK-emitted behaviour so + * future regressions are caught early. + */ +@WireMockTest +@DisplayName("X-Client-ID header (v9)") +class XClientIdHeaderTest { + + @Test + @DisplayName("emits X-Client-ID: community when no clientId configured") + void communityDefault(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor( + post(urlEqualTo("/api/request")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"answer\":\"ok\"}}"))); + + AxonFlow client = + AxonFlow.create(AxonFlowConfig.builder().agentUrl(wmRuntimeInfo.getHttpBaseUrl()).build()); + + client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build()); + + verify( + postRequestedFor(urlEqualTo("/api/request")) + .withHeader("X-Client-ID", equalTo("community"))); + } + + @Test + @DisplayName("emits X-Client-ID matching configured clientId") + void configuredClient(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor( + post(urlEqualTo("/api/request")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"answer\":\"ok\"}}"))); + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .agentUrl(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("acme-corp") + .clientSecret("secret") + .build()); + + client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build()); + + verify( + postRequestedFor(urlEqualTo("/api/request")) + .withHeader("X-Client-ID", equalTo("acme-corp"))); + } + + @Test + @DisplayName("does NOT emit legacy X-Tenant-ID") + void noLegacyTenantHeader(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor( + post(urlEqualTo("/api/request")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"data\":{\"answer\":\"ok\"}}"))); + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .agentUrl(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("acme-corp") + .clientSecret("secret") + .build()); + + client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build()); + + verify(postRequestedFor(urlEqualTo("/api/request")).withoutHeader("X-Tenant-ID")); + } +} From a29d7a23ceb7f293fa0906679abaf2f099a605b2 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 19 May 2026 23:26:41 +0200 Subject: [PATCH 2/2] test(runtime-e2e): runtime-e2e/x-client-id/ proves SDK emits v9 header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md HARD RULE #0: gate requires runtime-e2e// test for user-facing surface changes. Adds the v9 X-Client-ID counterpart to x-axonflow-client/ runner — spawns an in-process forwarding proxy (JDK HttpServer + HttpClient) that captures the outbound X-Client-ID off the wire and forwards to the real agent. Asserts captured value equals the configured clientId. Prereqs: AXONFLOW_AGENT_URL, AXONFLOW_TENANT_ID, AXONFLOW_TENANT_SECRET. Signed-off-by: Saurabh Jain --- runtime-e2e/x-client-id/SdkXClientIdTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 runtime-e2e/x-client-id/SdkXClientIdTest.java diff --git a/runtime-e2e/x-client-id/SdkXClientIdTest.java b/runtime-e2e/x-client-id/SdkXClientIdTest.java new file mode 100644 index 0000000..eba65b1 --- /dev/null +++ b/runtime-e2e/x-client-id/SdkXClientIdTest.java @@ -0,0 +1,99 @@ +/* + * runtime-e2e/x-client-id/SdkXClientIdTest.java + * + * Per CLAUDE.md HARD RULE #0: real-wire test of the SDK's v9 X-Client-ID + * header emission to a real running AxonFlow agent. + * + * Approach: the SDK does not expose its internal OkHttpClient, so we + * run a tiny in-process forwarding proxy (Java's HttpServer + the JDK's + * built-in HttpClient) that inspects every request, captures the + * X-Client-ID header, and forwards to the real agent. The SDK is + * pointed at the proxy. Bytes flow real → real. + * + * Run: + * AXONFLOW_AGENT_URL=http://localhost:8080 \ + * AXONFLOW_TENANT_ID=cs_... AXONFLOW_TENANT_SECRET=... \ + * java -cp ":" runtime-e2e/x-client-id/SdkXClientIdTest.java + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.types.MCPCheckInputResponse; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.atomic.AtomicReference; + +public class SdkXClientIdTest { + public static void main(String[] args) throws Exception { + String endpoint = System.getenv().getOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080"); + String tenant = System.getenv("AXONFLOW_TENANT_ID"); + String secret = System.getenv("AXONFLOW_TENANT_SECRET"); + if (tenant == null || secret == null) { + System.err.println( + "AXONFLOW_TENANT_ID + AXONFLOW_TENANT_SECRET must be set; see ../README.md"); + System.exit(2); + } + + final URI target = URI.create(endpoint); + final HttpClient forwarder = HttpClient.newHttpClient(); + final AtomicReference sawClientId = new AtomicReference<>(""); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + ex -> { + sawClientId.set(ex.getRequestHeaders().getFirst("X-Client-ID")); + byte[] body = ex.getRequestBody().readAllBytes(); + HttpRequest.Builder b = + HttpRequest.newBuilder(target.resolve(ex.getRequestURI())) + .method(ex.getRequestMethod(), HttpRequest.BodyPublishers.ofByteArray(body)); + ex.getRequestHeaders() + .forEach( + (k, vs) -> { + if (!k.equalsIgnoreCase("host") && !k.equalsIgnoreCase("content-length")) { + vs.forEach(v -> b.header(k, v)); + } + }); + try { + HttpResponse resp = + forwarder.send(b.build(), HttpResponse.BodyHandlers.ofByteArray()); + byte[] respBody = resp.body(); + ex.sendResponseHeaders(resp.statusCode(), respBody.length); + ex.getResponseBody().write(respBody); + ex.getResponseBody().close(); + } catch (Exception e) { + ex.sendResponseHeaders(502, 0); + ex.close(); + } + }); + server.start(); + String proxyUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .agentUrl(proxyUrl) + .clientId(tenant) + .clientSecret(secret) + .build()); + + System.out.println("Asserting wire X-Client-ID = " + tenant); + try { + MCPCheckInputResponse r = client.mcpCheckInput("postgres", "SELECT 1"); + // outcome doesn't matter; only the captured header + } catch (Exception ignored) { + // outcome doesn't matter; only the captured header + } + server.stop(0); + + String got = sawClientId.get(); + if (!tenant.equals(got)) { + System.err.println("FAIL: wire X-Client-ID = \"" + got + "\", want \"" + tenant + "\""); + System.exit(1); + } + System.out.println("PASS: wire X-Client-ID = \"" + got + "\""); + } +}