Skip to content

Commit fb5ac6e

Browse files
test(e2e): add mtls datapath scenario for Kind
Exercise test-mode workload PKI, Traefik websecure mTLS termination, adapter enroll, and session-bound client certificates without relying on governance headers. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0c98f76 commit fb5ac6e

4 files changed

Lines changed: 266 additions & 5 deletions

File tree

test/e2e/kind.sh

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ set -euo pipefail
1010
#
1111
# Set E2E_SCENARIOS to a comma-separated subset for local debugging.
1212
# Supported values: all, smoke-auth, governance, trust, oauth, observability,
13-
# multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform.
13+
# multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform, mtls.
1414
# observability requires the full traffic suite: smoke-auth, governance, trust, oauth.
1515
#
1616
# Set E2E_DEEP_REQUEST_FLOWS=1 for pre-release runs that should exercise
@@ -366,11 +366,11 @@ validate_scenarios() {
366366
local scenario
367367
for scenario in "${E2E_SCENARIO_LIST[@]}"; do
368368
case "${scenario}" in
369-
all|smoke-auth|governance|trust|oauth|observability|multitenancy|api-platform|ui-auth|adapter-proxy|cli-platform)
369+
all|smoke-auth|governance|trust|oauth|observability|multitenancy|api-platform|ui-auth|adapter-proxy|cli-platform|mtls)
370370
;;
371371
*)
372372
echo "unsupported E2E scenario: ${scenario}" >&2
373-
echo "supported values: all, smoke-auth, governance, trust, oauth, observability, multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform" >&2
373+
echo "supported values: all, smoke-auth, governance, trust, oauth, observability, multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform, mtls" >&2
374374
exit 1
375375
;;
376376
esac
@@ -388,7 +388,7 @@ validate_scenarios() {
388388

389389
if deep_request_flows_enabled; then
390390
local required
391-
for required in smoke-auth governance trust oauth observability multitenancy api-platform ui-auth adapter-proxy cli-platform; do
391+
for required in smoke-auth governance trust oauth observability multitenancy api-platform ui-auth adapter-proxy cli-platform mtls; do
392392
if ! scenario_selected "${required}"; then
393393
echo "E2E_DEEP_REQUEST_FLOWS=1 requires all E2E scenarios" >&2
394394
echo "set E2E_SCENARIOS=all or include every supported scenario" >&2
@@ -960,6 +960,9 @@ ensure_gateway_port_forward() {
960960
wait_port "${SENTINEL_PORT}"
961961
}
962962

963+
# shellcheck source=scenarios/mtls.sh
964+
source "${PROJECT_ROOT}/test/e2e/scenarios/mtls.sh"
965+
963966
refresh_mcp_proxy_urls() {
964967
MCP_INGRESS_PATH="/${SERVER_NAME}/mcp"
965968
MCP_DIRECT_URL="http://127.0.0.1:${TRAEFIK_PORT}${MCP_INGRESS_PATH}"
@@ -6055,6 +6058,11 @@ fi
60556058

60566059
fi
60576060

6061+
if scenario_selected "mtls"; then
6062+
run_e2e_mtls_scenario
6063+
cleanup_mcp_server_and_wait "${MTLS_SERVER_NAME}" mcp-servers 120s
6064+
fi
6065+
60586066
echo "[cli] checking sentinel restart command"
60596067
# The full E2E stack packs single-node Kind tightly, so avoid requiring surge CPU for this restart smoke.
60606068
refresh_kind_kubeconfig

test/e2e/scenarios/mtls.sh

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# mTLS datapath checks for Kind E2E. Sourced from kind.sh when the mtls scenario
2+
# is selected. Requires test-mode setup (managed mcp-runtime-ca issuer).
3+
4+
MTLS_SERVER_NAME="${MTLS_SERVER_NAME:-mtls-mcp-server}"
5+
MTLS_TRUST_DOMAIN="${MTLS_TRUST_DOMAIN:-cluster.local}"
6+
TRAEFIK_TLS_PORT="${TRAEFIK_TLS_PORT:-18443}"
7+
MTLS_AGENT_ID="${MTLS_AGENT_ID:-e2e-mtls-agent}"
8+
9+
run_e2e_mtls_scenario() {
10+
log_line mtls "verifying test-mode workload PKI"
11+
if ! kubectl get clusterissuer mcp-runtime-ca >/dev/null 2>&1; then
12+
echo "expected ClusterIssuer mcp-runtime-ca from test-mode setup" >&2
13+
exit 1
14+
fi
15+
kubectl wait --for=condition=Ready clusterissuer/mcp-runtime-ca --timeout=120s >/dev/null
16+
17+
operator_issuer="$(kubectl get deploy/mcp-runtime-operator-controller-manager -n mcp-runtime \
18+
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="MCP_MTLS_CLUSTER_ISSUER")].value}')"
19+
if [[ "${operator_issuer}" != "mcp-runtime-ca" ]]; then
20+
echo "operator MCP_MTLS_CLUSTER_ISSUER = ${operator_issuer:-<empty>}, want mcp-runtime-ca" >&2
21+
exit 1
22+
fi
23+
24+
runtime_issuer="$(kubectl get deploy/mcp-runtime-api -n mcp-sentinel \
25+
-o jsonpath='{.spec.template.spec.containers[?(@.name=="runtime-api")].env[?(@.name=="MCP_MTLS_CLUSTER_ISSUER")].value}' 2>/dev/null || true)"
26+
if [[ -n "${runtime_issuer}" && "${runtime_issuer}" != "mcp-runtime-ca" ]]; then
27+
echo "runtime-api MCP_MTLS_CLUSTER_ISSUER = ${runtime_issuer}, want mcp-runtime-ca" >&2
28+
exit 1
29+
fi
30+
31+
log_line mtls "deploying auth.mode mtls MCPServer ${MTLS_SERVER_NAME}"
32+
MTLS_METADATA="${WORKDIR}/mtls-metadata.yaml"
33+
MTLS_MANIFEST="${WORKDIR}/mtls-manifest.yaml"
34+
MTLS_SECRET_NAME="${MTLS_SERVER_NAME}-analytics-creds"
35+
MTLS_IMAGE="registry.registry.svc.cluster.local:5000/${MTLS_SERVER_NAME}:${E2E_WORKLOAD_TAG}"
36+
kubectl create secret generic "${MTLS_SECRET_NAME}" \
37+
-n mcp-servers \
38+
--from-literal=api-key="${INGEST_API_KEY}" \
39+
--dry-run=client -o yaml | kubectl apply -f -
40+
cat > "${MTLS_METADATA}" <<EOF
41+
version: v1
42+
servers:
43+
- name: ${MTLS_SERVER_NAME}
44+
image: ${MTLS_IMAGE%:*}
45+
imageTag: ${MTLS_IMAGE##*:}
46+
route: /${MTLS_SERVER_NAME}/mcp
47+
publicPathPrefix: ${MTLS_SERVER_NAME}
48+
port: 8090
49+
namespace: mcp-servers
50+
resources:
51+
requests:
52+
cpu: 1m
53+
memory: 32Mi
54+
envVars:
55+
- name: PORT
56+
value: "8090"
57+
- name: MCP_PATH
58+
value: "/${MTLS_SERVER_NAME}/mcp"
59+
tools:
60+
- name: aaa-ping
61+
requiredTrust: low
62+
sideEffect: read
63+
auth:
64+
mode: mtls
65+
trustDomain: ${MTLS_TRUST_DOMAIN}
66+
policy:
67+
mode: allow-list
68+
defaultDecision: deny
69+
policyVersion: v1
70+
session:
71+
required: true
72+
gateway:
73+
enabled: true
74+
resources:
75+
requests:
76+
cpu: 1m
77+
memory: 32Mi
78+
analytics:
79+
ingestURL: "http://mcp-sentinel-ingest.mcp-sentinel.svc.cluster.local:8081/events"
80+
apiKeySecretRef:
81+
name: ${MTLS_SECRET_NAME}
82+
key: api-key
83+
EOF
84+
85+
if ! docker image inspect "${MTLS_IMAGE}" >/dev/null 2>&1; then
86+
run_logged_stage "build mtls MCP server image" ./bin/mcp-runtime server build image "${MTLS_SERVER_NAME}" \
87+
--metadata-file "${MTLS_METADATA}" \
88+
--dockerfile "${GO_EXAMPLE_SOURCE_DIR}/Dockerfile" \
89+
--registry registry.registry.svc.cluster.local:5000 \
90+
--tag "${E2E_WORKLOAD_TAG}" \
91+
--context "${GO_EXAMPLE_SOURCE_DIR}"
92+
fi
93+
prune_kind_image "${MTLS_IMAGE}"
94+
load_image_into_kind "${MTLS_IMAGE}"
95+
96+
run_logged_stage "deploy mtls MCP server manifests" bash -lc \
97+
"MCP_RUNTIME_CONFIG_DIR=\"${E2E_PIPELINE_CONFIG_DIR}\" ./bin/mcp-runtime server generate --metadata-file \"${MTLS_METADATA}\" --output \"${WORKDIR}/mtls-manifests\" && ./bin/mcp-runtime server --use-kube apply --file \"${WORKDIR}/mtls-manifests/${MTLS_SERVER_NAME}.yaml\""
98+
wait_for_named_server_ready "${MTLS_SERVER_NAME}" "mcp-servers" 240
99+
100+
log_line mtls "waiting for gateway and trust-bundle certificates"
101+
deadline=$((SECONDS + 240))
102+
while (( SECONDS < deadline )); do
103+
if kubectl get secret "${MTLS_SERVER_NAME}-gateway-mtls" -n mcp-servers >/dev/null 2>&1 \
104+
&& kubectl get secret "${MTLS_SERVER_NAME}-mtls-ca" -n mcp-servers >/dev/null 2>&1; then
105+
break
106+
fi
107+
sleep 2
108+
done
109+
if ! kubectl get secret "${MTLS_SERVER_NAME}-gateway-mtls" -n mcp-servers >/dev/null 2>&1; then
110+
echo "timed out waiting for gateway TLS secret ${MTLS_SERVER_NAME}-gateway-mtls" >&2
111+
exit 1
112+
fi
113+
114+
ensure_traefik_port_forward
115+
if [[ -z "${TRAEFIK_TLS_PORT_FORWARD_PID:-}" ]]; then
116+
echo "[port-forward] exposing traefik websecure on localhost:${TRAEFIK_TLS_PORT}"
117+
port_forward_bg traefik traefik "${TRAEFIK_TLS_PORT}" 8443 "${WORKDIR}/traefik-tls-port-forward.log"
118+
TRAEFIK_TLS_PORT_FORWARD_PID="${LAST_MANAGED_PID}"
119+
fi
120+
wait_port "${TRAEFIK_TLS_PORT}"
121+
122+
MTLS_INGRESS_PATH="/${MTLS_SERVER_NAME}/mcp"
123+
MTLS_URL="https://127.0.0.1:${TRAEFIK_TLS_PORT}${MTLS_INGRESS_PATH}"
124+
MTLS_CA_FILE="${WORKDIR}/mtls-ca.crt"
125+
kubectl get secret "${MTLS_SERVER_NAME}-mtls-ca" -n mcp-servers -o jsonpath='{.data.ca\.crt}' | decode_base64 >"${MTLS_CA_FILE}"
126+
127+
log_line mtls "rejecting initialize without a client certificate"
128+
if curl -fsS --cacert "${MTLS_CA_FILE}" \
129+
-H "Host: ${SERVER_HOST}" \
130+
-H "content-type: application/json" \
131+
-H "accept: application/json, text/event-stream" \
132+
-H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \
133+
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}' \
134+
"${MTLS_URL}" >/dev/null 2>&1; then
135+
echo "expected initialize without client certificate to fail" >&2
136+
exit 1
137+
fi
138+
139+
ensure_gateway_port_forward
140+
ensure_api_port_forward
141+
if [[ -z "${ADAPTER_PLATFORM_TOKEN:-}" ]]; then
142+
ADAPTER_PLATFORM_TOKEN="$(PLATFORM_ADMIN_EMAIL="${PLATFORM_ADMIN_EMAIL}" PLATFORM_ADMIN_PASSWORD="${PLATFORM_ADMIN_PASSWORD}" python3 -c '
143+
import json, os
144+
print(json.dumps({"email": os.environ["PLATFORM_ADMIN_EMAIL"], "password": os.environ["PLATFORM_ADMIN_PASSWORD"]}))
145+
' | curl -fsS -X POST \
146+
-H "content-type: application/json" \
147+
--data-binary @- \
148+
"http://127.0.0.1:${API_SERVICE_PORT}/api/v1/auth/login" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
149+
fi
150+
151+
log_line mtls "applying grant and adapter session for mtls datapath"
152+
cat >"${WORKDIR}/mtls-grant.yaml" <<EOF
153+
apiVersion: mcpruntime.org/v1alpha1
154+
kind: MCPAccessGrant
155+
metadata:
156+
name: ${MTLS_SERVER_NAME}-grant
157+
namespace: mcp-servers
158+
spec:
159+
serverRef:
160+
name: ${MTLS_SERVER_NAME}
161+
subject:
162+
agentID: ${MTLS_AGENT_ID}
163+
maxTrust: low
164+
allowedSideEffects: [read]
165+
policyVersion: v1
166+
toolRules:
167+
- name: aaa-ping
168+
decision: allow
169+
EOF
170+
(cd "${WORKDIR}" && "${PROJECT_ROOT}/bin/mcp-runtime" access --use-kube grant apply --file mtls-grant.yaml)
171+
wait_for_policy_text "\"name\": \"${MTLS_SERVER_NAME}-grant\""
172+
173+
MTLS_CERT_DIR="${WORKDIR}/mtls-certs"
174+
mkdir -p "${MTLS_CERT_DIR}"
175+
MTLS_ENROLL_OUT="$(MCP_PLATFORM_API_URL="http://127.0.0.1:${SENTINEL_PORT}" \
176+
MCP_PLATFORM_API_TOKEN="${ADAPTER_PLATFORM_TOKEN}" \
177+
./bin/mcp-runtime adapter enroll \
178+
--server "${MTLS_SERVER_NAME}" \
179+
--namespace mcp-servers \
180+
--agent "${MTLS_AGENT_ID}" \
181+
--trust-domain "${MTLS_TRUST_DOMAIN}" \
182+
--output-dir "${MTLS_CERT_DIR}")"
183+
MTLS_SESSION_NAME="$(printf '%s\n' "${MTLS_ENROLL_OUT}" | python3 -c 'import re,sys; m=re.search(r"/session/([^\\s]+)", sys.stdin.read()); print(m.group(1) if m else ""); sys.exit(0 if m else 1)')"
184+
wait_for_policy_text "\"name\": \"${MTLS_SESSION_NAME}\""
185+
186+
MTLS_CLIENT_CERT="${MTLS_CERT_DIR}/client.crt"
187+
MTLS_CLIENT_KEY="${MTLS_CERT_DIR}/client.key"
188+
MTLS_CLIENT_CA="${MTLS_CERT_DIR}/ca.crt"
189+
for f in "${MTLS_CLIENT_CERT}" "${MTLS_CLIENT_KEY}" "${MTLS_CLIENT_CA}"; do
190+
if [[ ! -s "${f}" ]]; then
191+
echo "adapter enroll did not write ${f}" >&2
192+
exit 1
193+
fi
194+
done
195+
196+
log_line mtls "accepting initialize with session-bound client certificate"
197+
init_body="$(curl -fsS \
198+
--cert "${MTLS_CLIENT_CERT}" \
199+
--key "${MTLS_CLIENT_KEY}" \
200+
--cacert "${MTLS_CLIENT_CA}" \
201+
-H "Host: ${SERVER_HOST}" \
202+
-H "content-type: application/json" \
203+
-H "accept: application/json, text/event-stream" \
204+
-H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \
205+
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"e2e-mtls","version":"1"}}}' \
206+
"${MTLS_URL}")"
207+
echo "${init_body}" | python3 -c '
208+
import json, sys
209+
doc = json.load(sys.stdin)
210+
if doc.get("error"):
211+
raise SystemExit(f"initialize failed: {doc}")
212+
if "result" not in doc:
213+
raise SystemExit(f"missing initialize result: {doc}")
214+
print("mtls initialize ok")
215+
'
216+
217+
log_line mtls "ignoring spoofed governance headers on mtls path"
218+
spoof_body="$(curl -fsS \
219+
--cert "${MTLS_CLIENT_CERT}" \
220+
--key "${MTLS_CLIENT_KEY}" \
221+
--cacert "${MTLS_CLIENT_CA}" \
222+
-H "Host: ${SERVER_HOST}" \
223+
-H "content-type: application/json" \
224+
-H "accept: application/json, text/event-stream" \
225+
-H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \
226+
-H "X-MCP-Human-ID: spoofed-human" \
227+
-H "X-MCP-Agent-ID: spoofed-agent" \
228+
-H "X-MCP-Agent-Session: definitely-not-${MTLS_SESSION_NAME}" \
229+
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"aaa-ping","arguments":{}}}' \
230+
"${MTLS_URL}" 2>/dev/null || true)"
231+
if [[ -n "${spoof_body}" ]]; then
232+
echo "${spoof_body}" | python3 -c '
233+
import json, sys
234+
doc = json.load(sys.stdin)
235+
err = doc.get("error") or {}
236+
msg = str(err.get("message", "")).lower()
237+
if doc.get("result") and "pong" in json.dumps(doc.get("result")).lower():
238+
raise SystemExit("spoofed governance headers must not bypass mtls auth")
239+
if err and any(token in msg for token in ("session", "denied", "unauthorized", "forbidden", "grant", "trust")):
240+
raise SystemExit(0)
241+
if err:
242+
raise SystemExit(0)
243+
raise SystemExit(f"unexpected tools/call response with spoofed headers: {doc}")
244+
'
245+
fi
246+
}

test/e2e/scenarios_test.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ run_valid "api-platform" "api-platform" "api-platform"
5757
run_valid "ui-auth" "ui-auth" "ui-auth"
5858
run_valid "adapter-proxy" "adapter-proxy" "adapter-proxy"
5959
run_valid "cli-platform" "cli-platform" "cli-platform"
60+
run_valid "mtls" "mtls" "mtls"
6061
run_valid "observability-with-deps" "smoke-auth,governance,trust,oauth,observability" "smoke-auth,governance,trust,oauth,observability"
6162
run_valid "whitespace-trimmed" " smoke-auth , governance " "smoke-auth,governance"
6263
run_valid "duplicates-deduped" "smoke-auth,smoke-auth" "smoke-auth"
@@ -140,7 +141,8 @@ selector_expect "api" "smoke-auth,api-platform" "services/platform-api/auth/logi
140141
selector_expect "runtime-tools-api" "smoke-auth,api-platform,cli-platform" "services/runtime-api/internal/runtimeapi/tools.go"
141142
selector_expect "catalog-cli" "smoke-auth,cli-platform" "internal/cli/catalog/catalog.go"
142143
selector_expect "adapter" "smoke-auth,adapter-proxy,governance" "internal/cli/adapter/proxy.go"
143-
selector_expect "gateway" "smoke-auth,governance,trust,oauth,adapter-proxy" "services/mcp-gateway/main.go"
144+
selector_expect "mtls-operator" "smoke-auth,mtls" "internal/operator/mtls.go"
145+
selector_expect "gateway" "smoke-auth,governance,trust,oauth,adapter-proxy,mtls" "services/mcp-gateway/main.go"
144146
selector_expect "observability" "smoke-auth,governance,trust,oauth,observability" "services/ingest/main.go"
145147
selector_expect "broad" "all" "api/v1alpha1/mcpserver_types.go"
146148

test/e2e/select_pr_scenarios.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ classify_path() {
5050
mark_all
5151
return
5252
;;
53+
internal/operator/mtls*|internal/cli/certmanager/*|traefik-plugins/spiffe-identity/*|config/cert-manager/*|pkg/identity/*|pkg/certauth/*|test/e2e/scenarios/mtls.sh)
54+
add_scenario "mtls"
55+
return
56+
;;
5357
api/*|cmd/operator/*|internal/operator/*|config/*|k8s/*|pkg/controlplane/*|pkg/k8sclient/*|pkg/kubeworkload/*|pkg/manifest/*|pkg/metadata/*)
5458
mark_all
5559
return
@@ -108,6 +112,7 @@ classify_path() {
108112
add_scenario "trust"
109113
add_scenario "oauth"
110114
add_scenario "adapter-proxy"
115+
add_scenario "mtls"
111116
return
112117
;;
113118
services/ingest/*|services/processor/*|pkg/clickhouse/*|pkg/events/*|pkg/sentinel/*|pkg/serviceutil/*)

0 commit comments

Comments
 (0)