Skip to content

Commit dac9bc4

Browse files
authored
fix(operator): make per-model API-key auth work end-to-end on AI Gateway v0.5 (#116) (#117)
* feat(operator): add ClientIDsFromSecret helper for api-key client IDs * feat(operator): scope API keys per-model via per-route authorization Add apiKeyAuth.forwardClientIDHeader + sanitize and a deny-by-default authorization block listing each model's own client IDs to the external SecurityPolicy. A key valid on the shared listener now gets 403 against a model it was not minted for. Covers LLMModel and PassthroughModel. Closes #116 * feat(operator): re-render api-key authorization when keys change Watch managed api-keys Secrets and enqueue the owning model so minting or revoking a key promptly updates the per-route authorization allow-list. * docs: describe per-model API-key authorization on the external endpoint * docs: fold redundant forwardClientIDHeader bullet into the authorization paragraph * fix(operator): drop Host matcher from AIGatewayRoute so models route on AI Gateway v0.5 The AI Gateway v0.5 controller does not register a model whose match rule carries any header beyond x-ai-eg-model. The operator added a Host matcher (defence-in-depth on top of sectionName) to every model route, which made every request 404 "model not configured in the Gateway", blocking all model serving on EG v1.6.7 / AI Gateway v0.5. Remove it from the served (buildAIGatewayRoute) and passthrough declared-model rules; sectionName already scopes each route to its listener. The passthrough catch-all rule is unchanged (opt-in, no x-ai-eg-model). Verified end-to-end on kind for served and passthrough models. * fix(operator): grant EPP RBAC for the inference scheduler's GIE watches The llm-d inference scheduler (EPP) v0.8.x watches InferenceObjective and InferenceModelRewrite (inference.networking.x-k8s.io). Without RBAC the informers never sync and the EPP crash-loops ("failed waiting for *v1alpha2.InferenceObjective Informer to sync"), so served models never get a healthy upstream. Add get/list/watch on the x-k8s.io inference resources to the per-model EPP Role. * feat(operator): pool api-key authentication across the shared listener EG's api_key_auth filter runs before the AI Gateway ext_proc resolves the model, so authentication happens on whichever catch-all route wins, not the model's own route. With per-route credentials only the winning model's keys authenticated; every other model's keys got 401. Pool every model's api-keys Secret into each external SecurityPolicy's credentialRefs so any valid key authenticates on the shared listener, while the per-route authorization block (deny-by-default + the model's own client IDs) still scopes each request to its model. All Secrets are same-namespace (#59). The controllers gather the full Secret set and re-reconcile all models when an api-keys Secret changes. Completes per-model API-key auth for #116: a key minted for one model returns 200 for that model and 403 for every other, verified end-to-end on kind for served and passthrough models. * chore(dev): mock-vllm answers chat completions for end-to-end testing The dev mock-vllm only served /health and /v1/models; add a 200 OpenAI-compatible response for POST /v1/chat/completions (and /v1/completions) so the dev stack can exercise the full served-model request path through the gateway. * fix(operator): grant the operator the inference.networking.x-k8s.io perms the EPP Role needs The per-model EPP Role grants get/list/watch on inferenceobjectives, inferencemodelrewrites, inferencepools and inferencepoolimports (inference.networking.x-k8s.io). Kubernetes privilege-escalation prevention blocks the operator from creating a Role that grants permissions the operator does not itself hold, so on a Helm/ArgoCD install every served LLMModel failed to reconcile with "attempting to grant RBAC permissions not currently held" - the EPP Role, and with it the model's SecurityPolicy and AIGatewayRoute, were never created. Add the matching get/list/watch rule to the operator ClusterRole so it can create the EPP Role. * fix(operator): set explicit vLLM command for the CUDA serving image llm-d-cuda images use the NVIDIA CUDA wrapper as their entrypoint and ship no default CMD, so the served pod must supply the command itself. The vllm container set only Args (the vLLM flags); the wrapper exec'd them as the command ("exec: --: invalid option") and the pod crash-looped. Set the standard vLLM OpenAI-server command so the flags are its arguments. * fix(key-manager): scope API-key client IDs by model The client-id sequence was counted per-model, so the first key a user minted for any model was always "user-<name>-1". The operator pools every model's api-keys Secret into each model's SecurityPolicy credentialRefs (model-scoped auth) and uses the matched data key as the x-llm-client-id for per-model authorization. Identical client IDs across models collide in that pooled set: one model's key authenticates and authorizes for another, and a user's other keys fail to authenticate at all (only one value survives per duplicated key). Include the model in the client ID (user-<name>-<model>-<n>) so each is globally unique. Add a regression test asserting a user's keys for two models get distinct client IDs. * style(key-manager): gofmt handler_test.go struct field alignment * chore(docs): exclude architecture.md from content-parity PR #117 (#116) documents the now-enabled per-model API-key authorization in architecture.md, so its body intentionally diverges from the frozen Hugo source. Matches the existing local-development.md/installation.md exclusions. * fix(operator): use constants for authz actions and client-ID header golangci-lint (goconst) on current main flagged the repeated "Deny"/"Allow" authorization-action literals and the "x-llm-client-id" header literal, which already has the apiKeyClientIDHeader constant. Add authzActionAllow/authzActionDeny constants in auth.go and reference all three constants from auth.go and the reconciler tests. No behavior change - the rendered SecurityPolicy is identical. * fix(key-manager): make client IDs collision-proof and never reuse live IDs Two defects in the client-ID scheme, both regression-tested: - "user-<name>-<model>-<n>" with hyphen separators is ambiguous: the pairs (mary, jane-chat) and (mary-jane, chat) composed the same clientID in two different Secrets, recreating the #122 pooled-credential collision. An FNV-1a hash of the raw (username, model) pair is now part of every clientID, so distinct pairs always mint distinct IDs. - The sequence number came from counting existing keys, so minting after a mid-sequence revocation reused a still-live clientID and silently overwrote that key in the Secret and its ConfigMap metadata. The sequence now probes upward until the ID is free in both. * feat(operator): allow overriding the vLLM container command per model Adds spec.serving.command to the LLMModel CRD. The default stays the explicit vLLM OpenAI-server entrypoint required by the llm-d-cuda image (its NVIDIA wrapper entrypoint ships no default CMD); the override lets a custom serving.image use its own launcher. The dev mock-vllm model needs it: forcing the vLLM command broke the kind dev served path, since the mock image has no vllm module and crash-looped with ModuleNotFoundError. dev/manifests/test-model.yaml now sets command: ["python", "/server.py"]. configuration.md documents the field and joins the content-parity exclusion list, following the same pattern as architecture.md. * fix(dev): grant the operator the GIE x-k8s.io RBAC in the dev manifests The #121 fix added inference.networking.x-k8s.io get/list/watch to the Helm chart ClusterRole but not to dev/manifests/operator.yaml, so on the kind dev stack RBAC escalation prevention still blocked the per-model EPP Role and every served model failed to reconcile ("attempting to grant RBAC permissions not currently held"). Mirrors the chart rule. * refactor(operator): consolidate the api-keys Secret watch plumbing The LLMModel and PassthroughModel controllers carried near-verbatim copies of the api-keys Secret helpers (client-ID read, pooled Secret listing, watch predicate, map function). They now share internal/controller/apikeys.go, the "-api-keys" suffix is single-sourced next to reconcilers.APIKeySecretName, and a failed List in the map function is logged instead of silently dropping the re-render. * docs: record the validated AI Gateway v0.5 routing and client-ID contract architecture.md still described the removed Host matcher, the old two-header precedence rationale, and the pre-#117 client-ID format. It now documents the behavior validated live on EG v1.6.7 / AI Gateway v0.5: - dispatch is decided by the ext_proc model registry, so served models win regardless of route age (verified against an older catchAll: true route) - catchAll is currently inert: unregistered model ids 404 at the ext_proc before route matching runs - key activation is split into its authentication (Secret sync, immediate) and authorization (operator re-render) halves - the data-model examples use the pair-hashed client-ID format The buildPassthroughRoute comments state the same validated behavior. * docs: finish aligning prose with pooled authentication Follow-up drift sweep across docs/src/content/docs against the code at this branch's head: - architecture.md: explain why the pooled apiKeyAuth credentialRefs live on each per-route SecurityPolicy instead of one gateway-level policy (route-level policies take precedence over gateway-level ones rather than merging, and each route needs its own policy for the per-model authorization anyway), and note the deliberate exception to the cluster-singleton rule; fix the resources-table row that still said the external policy references only the per-model Secret; the PassthroughModel setup bullet now mentions the api-keys Secret watch. - installation.md: the wrong-model-key troubleshooting entry sat under 401, but pooled authentication makes that case a 403; split into 401 (key in no Secret) and 403 (key valid, not on this model's allow-list, or allow-list not yet re-rendered). - local-development.md and the dev passthrough manifest: note the transient 403 window between patching a key into the Secret and the operator re-rendering the allow-list. * fix(key-manager): use truncated SHA-256 for client-ID uniqueness The client-ID appended a 32-bit FNV-1a hash of the (username, model) pair, plus a 32-bit FNV-1a hash when sanitizing usernames. A 32-bit space makes collisions cheap to construct, and a duplicate client ID across two models' pooled api-keys Secrets reintroduces the cross-model authorization bypass (Envoy Gateway uses the first of any duplicated client ID in credentialRefs; see #116, #122). Replace both with a 128-bit-truncated SHA-256. The hash input already uses a NUL separator so it was unambiguous; only the output width was weak. The client ID is a public authorization principal, not a secret, so a deterministic hash keeps IDs reproducible for tests and debugging while making collisions computationally infeasible. Update the black-box mirror helpers and the white-box drift-detector literals to match. * test(dev): add gateway per-model scope + client-ID anti-spoof e2e test test-model-scope.sh drives the real external gateway data path: it deploys two served mock models, injects a known API key into each model's api-keys Secret, and asserts the 200/403/401 matrix. The security-critical rows prove a client-supplied x-llm-client-id header (including duplicate, mixed-case, and comma-joined forms) cannot grant access to a model the key is not scoped to: Envoy Gateway's api_key_auth filter sets the forwarded client-ID header with a replace-all "set", discarding any client value before authorization runs. Locks that behavior in against a gateway/Envoy version bump.
1 parent 7293c88 commit dac9bc4

33 files changed

Lines changed: 1346 additions & 158 deletions

charts/nebari-llm-serving/crds/llmmodel-crd.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,17 @@ spec:
13361336
serving:
13371337
description: serving configures the vLLM serving layer
13381338
properties:
1339+
command:
1340+
description: |-
1341+
command overrides the container command for the vLLM container.
1342+
Defaults to the standard vLLM OpenAI-server entrypoint
1343+
(python3 -m vllm.entrypoints.openai.api_server), which the default
1344+
serving image requires because its entrypoint is the NVIDIA CUDA
1345+
wrapper with no default CMD. Set this when a custom serving image
1346+
needs a different launcher; vllmArgs are appended as arguments.
1347+
items:
1348+
type: string
1349+
type: array
13391350
dataParallelism:
13401351
default: 1
13411352
description: dataParallelism controls data parallelism

charts/nebari-llm-serving/templates/operator-clusterrole.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ rules:
4141
- apiGroups: ["inference.networking.k8s.io"]
4242
resources: ["inferencepools"]
4343
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
44+
# The operator creates a per-model EPP Role granting these
45+
# inference.networking.x-k8s.io permissions (see reconcilers/inferencepool.go).
46+
# RBAC privilege-escalation prevention requires the operator to already hold
47+
# any permission it grants, so it must carry them here too.
48+
- apiGroups: ["inference.networking.x-k8s.io"]
49+
resources: ["inferenceobjectives", "inferencemodelrewrites", "inferencepools", "inferencepoolimports"]
50+
verbs: ["get", "list", "watch"]
4451
- apiGroups: ["aigateway.envoyproxy.io"]
4552
resources: ["aigatewayroutes", "aiservicebackends", "backendsecuritypolicies"]
4653
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

dev/Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ AI_GATEWAY_VERSION ?= v0.5.0
2424
HELM_MAJOR := $(shell helm version --template '{{.Version}}' 2>/dev/null | sed -E 's/^v?([0-9]+).*/\1/')
2525
HELM_FORCE_CONFLICTS := $(if $(filter-out 1 2 3,$(HELM_MAJOR)),--force-conflicts,)
2626

27-
.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager deploy-keycloak apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui test-auth logs-operator logs-key-manager logs-keycloak pf-key-manager pf-keycloak clean help
27+
.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager deploy-keycloak apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui test-auth test-model-scope logs-operator logs-key-manager logs-keycloak pf-key-manager pf-keycloak clean help
2828

2929
help: ## Show this help
3030
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
@@ -113,6 +113,9 @@ ui: ## Run the React UI dev server against the local cluster (alias for run-dev)
113113
test-auth: ## Verify the key-manager's real bearer-token JWKS validation (deploys Keycloak, flips off dev mode, mints a token, asserts 200/401). KEEP_AUTH=1 leaves real-auth mode on.
114114
./test-auth.sh
115115

116+
test-model-scope: ## Verify per-model API-key scoping and x-llm-client-id anti-spoofing through the gateway (deploys two mock models, asserts the 200/403/401 matrix). KEEP=1 leaves the models.
117+
./test-model-scope.sh
118+
116119
logs-operator: ## Tail operator logs
117120
$(KUBECTL) -n llm-operator-system logs -f deployment/llm-operator
118121

dev/manifests/operator.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ rules:
5656
- apiGroups: ["inference.networking.k8s.io"]
5757
resources: ["inferencepools"]
5858
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
59+
# The operator creates a per-model EPP Role granting these
60+
# inference.networking.x-k8s.io permissions (see reconcilers/inferencepool.go).
61+
# RBAC privilege-escalation prevention requires the operator to already hold
62+
# any permission it grants, so it must carry them here too (mirrors the Helm
63+
# chart ClusterRole; #121).
64+
- apiGroups: ["inference.networking.x-k8s.io"]
65+
resources: ["inferenceobjectives", "inferencemodelrewrites", "inferencepools", "inferencepoolimports"]
66+
verbs: ["get", "list", "watch"]
5967
- apiGroups: ["aigateway.envoyproxy.io"]
6068
resources: ["aigatewayroutes", "aiservicebackends", "backendsecuritypolicies"]
6169
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

dev/manifests/passthrough-test-model.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
#
66
# Once reconciled (kubectl -n llm-operator-system get passthroughmodel), reach
77
# it through the gateway. The external endpoint uses API-key auth, so inject a
8-
# client key into the api-keys Secret to skip the key-manager:
8+
# client key into the api-keys Secret to skip the key-manager. A curl right
9+
# after the patch can 403 until the operator re-renders the SecurityPolicy
10+
# allow-list from the Secret (a few seconds) - retry on 403:
911
# kubectl -n llm-operator-system patch secret openrouter-api-keys --type merge \
1012
# -p '{"stringData":{"localtester":"sk-localtest-abc123"}}'
1113
# kubectl -n envoy-gateway-system port-forward svc/envoy-...-nebari-gateway 8443:443 &

dev/manifests/test-model.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ spec:
2323
memory: "256Mi"
2424
serving:
2525
image: mock-vllm:dev
26+
# The operator defaults the container command to the real vLLM entrypoint
27+
# (required by the llm-d-cuda serving image); the mock image runs a plain
28+
# Python HTTP server instead, so override it here.
29+
command: ["python", "/server.py"]
2630
replicas: 1
2731
monitoring:
2832
enabled: false

dev/mock-vllm/server.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Mock vLLM server that responds to health check and model list endpoints."""
1+
"""Mock vLLM server that responds to health check, model list, and chat endpoints."""
22
import json
33
from http.server import HTTPServer, BaseHTTPRequestHandler
44

@@ -19,6 +19,32 @@ def do_GET(self):
1919
self.send_response(404)
2020
self.end_headers()
2121

22+
def do_POST(self):
23+
# Minimal OpenAI-compatible chat-completion response so the dev stack can
24+
# exercise the full request path (gateway auth/routing -> backend -> 200)
25+
# without a real model.
26+
if self.path in ("/v1/chat/completions", "/v1/completions"):
27+
length = int(self.headers.get("Content-Length", 0) or 0)
28+
if length:
29+
self.rfile.read(length)
30+
self.send_response(200)
31+
self.send_header("Content-Type", "application/json")
32+
self.end_headers()
33+
body = json.dumps({
34+
"id": "chatcmpl-mock",
35+
"object": "chat.completion",
36+
"model": "test/tiny-model",
37+
"choices": [{
38+
"index": 0,
39+
"message": {"role": "assistant", "content": "ok"},
40+
"finish_reason": "stop",
41+
}],
42+
})
43+
self.wfile.write(body.encode())
44+
else:
45+
self.send_response(404)
46+
self.end_headers()
47+
2248
def log_message(self, format, *args):
2349
pass # suppress logs
2450

dev/test-model-scope.sh

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env bash
2+
# Prove per-model API-key scoping AND that a client cannot spoof the
3+
# x-llm-client-id authorization header (issues #116 / #122; PR #117 security
4+
# assessment finding R-04) end-to-end through the real gateway data path.
5+
#
6+
# Envoy Gateway authenticates once on the shared external listener (every
7+
# model's api-keys Secret is pooled into credentialRefs), then each model's
8+
# HTTPRoute carries a deny-by-default SecurityPolicy whose single Allow rule
9+
# lists only that model's client IDs, matched on the x-llm-client-id header.
10+
# The api_key_auth filter populates that header with setReferenceKey (a
11+
# replace-all "set"), so any client-supplied x-llm-client-id - duplicate,
12+
# mixed-case, or comma-joined - is discarded before the authorization filter
13+
# runs. This script locks that behavior in against a gateway/Envoy version bump.
14+
#
15+
# It deploys two served mock models (scope-a, scope-b), injects a known API key
16+
# into each model's api-keys Secret (skipping the key-manager, exactly as
17+
# local-development.md does), then drives the external gateway with a request
18+
# matrix. The security-critical assertions are the negative ones (403/401): the
19+
# gateway returns those from its authorization filter before proxying to any
20+
# backend, so they hold even if a mock backend is not ready.
21+
#
22+
# key for A -> model A : 200 (positive control)
23+
# key for A -> model B : 403 (cross-model denied)
24+
# key for A + spoof x-llm-client-id=<B id> -> B : 403 (R-04: header ignored)
25+
# key for A + duplicate spoof header -> B : 403 (R-04)
26+
# key for A + mixed-case spoof header -> B : 403 (R-04)
27+
# key for A + comma-joined spoof value -> B : 403 (R-04)
28+
# key for B -> model B : 200 (positive control)
29+
# invalid key -> model B : 401
30+
#
31+
# Cleanup deletes the two test models on exit. Set KEEP=1 to leave them.
32+
set -euo pipefail
33+
34+
cd "$(dirname "$0")"
35+
36+
CLUSTER_NAME="${CLUSTER_NAME:-llm-serving-test}"
37+
NS=llm-operator-system
38+
GW_NS=envoy-gateway-system
39+
GW_NAME=nebari-gateway
40+
GW_PORT="${GW_PORT:-8443}"
41+
HOST="llm.${LLM_BASE_DOMAIN:-local}"
42+
43+
# Pin kubectl to the kind cluster's context so this can never act on whatever
44+
# the current context happens to be (e.g. a remote/production cluster).
45+
KUBECTL="kubectl --context kind-${CLUSTER_NAME}"
46+
47+
# Two served mock models. The x-ai-eg-model header the AI Gateway derives from
48+
# the request body's "model" field is spec.model.name, so that is what we send.
49+
MODEL_A=scope-a
50+
MODEL_B=scope-b
51+
SERVE_A="test/scope-a"
52+
SERVE_B="test/scope-b"
53+
54+
# Client IDs and keys we inject directly into each model's api-keys Secret. We
55+
# choose the client IDs so we know B's id to attempt to spoof it from A.
56+
CID_A=clienta
57+
CID_B=clientb
58+
KEY_A="sk-scope-a-$(printf 'a%.0s' {1..24})"
59+
KEY_B="sk-scope-b-$(printf 'b%.0s' {1..24})"
60+
61+
# --- preflight -------------------------------------------------------------
62+
if ! $KUBECTL -n "$NS" get deploy/llm-operator >/dev/null 2>&1; then
63+
echo "ERROR: operator is not deployed. Run 'make deploy' (or './run-dev.sh') first." >&2
64+
exit 1
65+
fi
66+
if ! $KUBECTL -n "$GW_NS" get gateway "$GW_NAME" >/dev/null 2>&1; then
67+
echo "ERROR: gateway '$GW_NAME' not found. Run 'make setup' first." >&2
68+
exit 1
69+
fi
70+
71+
# --- deploy two served mock models -----------------------------------------
72+
apply_model() { # $1 = metadata.name $2 = spec.model.name
73+
$KUBECTL apply -f - >/dev/null <<YAML
74+
apiVersion: llm.nebari.dev/v1alpha1
75+
kind: LLMModel
76+
metadata:
77+
name: $1
78+
namespace: $NS
79+
spec:
80+
model:
81+
name: "$2"
82+
source: huggingface
83+
storage:
84+
type: emptyDir
85+
size: 1Gi
86+
preload: false
87+
resources:
88+
gpu:
89+
count: 0
90+
type: nvidia
91+
requests: { cpu: "100m", memory: "128Mi" }
92+
limits: { cpu: "200m", memory: "256Mi" }
93+
serving:
94+
image: mock-vllm:dev
95+
command: ["python", "/server.py"]
96+
replicas: 1
97+
monitoring: { enabled: false }
98+
access:
99+
public: true
100+
endpoints:
101+
external: { enabled: true, subdomain: $1 }
102+
internal: { enabled: true }
103+
YAML
104+
}
105+
106+
echo "==> deploying mock models '$MODEL_A' and '$MODEL_B'..."
107+
# The validating webhook may not be serving the instant the operator rollout
108+
# returns (it waits on the cert mount); retry so a momentary refusal does not
109+
# trip set -e.
110+
for attempt in $(seq 1 30); do
111+
if apply_model "$MODEL_A" "$SERVE_A" && apply_model "$MODEL_B" "$SERVE_B"; then break; fi
112+
[[ $attempt -eq 30 ]] && { echo "ERROR: operator webhook never became ready" >&2; exit 1; }
113+
sleep 2
114+
done
115+
116+
# --- cleanup ---------------------------------------------------------------
117+
PF_PID=""; PF_LOG=""
118+
cleanup() {
119+
[[ -n "$PF_PID" ]] && kill "$PF_PID" 2>/dev/null || true
120+
[[ -n "$PF_LOG" ]] && rm -f "$PF_LOG" 2>/dev/null || true
121+
if [[ "${KEEP:-0}" != "1" ]]; then
122+
echo "==> deleting mock models..."
123+
$KUBECTL -n "$NS" delete llmmodel "$MODEL_A" "$MODEL_B" --ignore-not-found >/dev/null 2>&1 || true
124+
else
125+
echo "==> KEEP=1: leaving mock models in place."
126+
fi
127+
}
128+
trap cleanup EXIT INT TERM
129+
130+
echo "==> waiting for models to report Ready..."
131+
$KUBECTL -n "$NS" wait llmmodel/"$MODEL_A" --for=jsonpath='{.status.phase}'=Ready --timeout=180s
132+
$KUBECTL -n "$NS" wait llmmodel/"$MODEL_B" --for=jsonpath='{.status.phase}'=Ready --timeout=180s
133+
134+
# --- inject known API keys into each model's api-keys Secret ----------------
135+
# The operator creates <model>-api-keys as an empty Opaque Secret; we add a
136+
# data key (the client ID) whose value is the API key, exactly as the
137+
# key-manager would. Patching the Secret re-triggers the operator to re-render
138+
# both models' SecurityPolicies (pooled credentialRefs + per-model allow-list).
139+
echo "==> injecting API keys (client IDs '$CID_A', '$CID_B')..."
140+
$KUBECTL -n "$NS" patch secret "${MODEL_A}-api-keys" --type merge \
141+
-p "{\"stringData\":{\"${CID_A}\":\"${KEY_A}\"}}" >/dev/null
142+
$KUBECTL -n "$NS" patch secret "${MODEL_B}-api-keys" --type merge \
143+
-p "{\"stringData\":{\"${CID_B}\":\"${KEY_B}\"}}" >/dev/null
144+
145+
# --- port-forward the external gateway --------------------------------------
146+
SVC="$($KUBECTL -n "$GW_NS" get svc \
147+
-l gateway.envoyproxy.io/owning-gateway-name="$GW_NAME" -o name | head -1)"
148+
if [[ -z "$SVC" ]]; then
149+
echo "ERROR: could not find the Envoy service for gateway '$GW_NAME'." >&2
150+
exit 1
151+
fi
152+
PF_LOG="$(mktemp)"
153+
echo "==> port-forwarding $SVC :$GW_PORT -> 443..."
154+
$KUBECTL -n "$GW_NS" port-forward "$SVC" "${GW_PORT}:443" >"$PF_LOG" 2>&1 &
155+
PF_PID=$!
156+
for _ in $(seq 1 40); do
157+
grep -q "Forwarding from" "$PF_LOG" 2>/dev/null && break
158+
sleep 0.5
159+
done
160+
161+
# --- request helpers --------------------------------------------------------
162+
GW="https://${HOST}:${GW_PORT}/v1/chat/completions"
163+
RESOLVE="${HOST}:${GW_PORT}:127.0.0.1"
164+
165+
# req MODEL BEARER [extra curl args...] -> echoes HTTP status code
166+
req() {
167+
local model="$1" bearer="$2"; shift 2
168+
curl -sk -o /dev/null -w '%{http_code}' --max-time 10 \
169+
--resolve "$RESOLVE" \
170+
-H "Authorization: Bearer ${bearer}" \
171+
-H "Content-Type: application/json" \
172+
"$@" \
173+
-d "{\"model\":\"${model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \
174+
"$GW"
175+
}
176+
177+
FAILED=0
178+
check() { # name expected actual
179+
if [[ "$2" == "$3" ]]; then
180+
echo " PASS: $1 (HTTP $3)"
181+
else
182+
echo " FAIL: $1 - expected $2, got $3"
183+
FAILED=1
184+
fi
185+
}
186+
187+
# --- wait for the gateway + allow-list to be ready --------------------------
188+
# The positive control returns 200 only once THREE things converge: the HTTPS
189+
# listener is Programmed (on a cold cluster this alone can take ~100s), Envoy
190+
# Gateway has synced the api-keys Secret for authentication, and the operator
191+
# has re-rendered the model's authorization allow-list. Poll it until 200 (or
192+
# give up after ~4m and let the assertions report the failure).
193+
echo "==> waiting for the gateway and SecurityPolicy allow-list to converge..."
194+
for _ in $(seq 1 120); do
195+
[[ "$(req "$SERVE_A" "$KEY_A")" == "200" ]] && break
196+
sleep 2
197+
done
198+
199+
# --- assertions -------------------------------------------------------------
200+
echo "==> running per-model scope + header-spoofing matrix against $GW"
201+
check "A's key -> model A (control)" 200 "$(req "$SERVE_A" "$KEY_A")"
202+
check "B's key -> model B (control)" 200 "$(req "$SERVE_B" "$KEY_B")"
203+
check "A's key -> model B (cross-model denied)" 403 "$(req "$SERVE_B" "$KEY_A")"
204+
check "invalid key -> model B" 401 "$(req "$SERVE_B" "not-a-real-key")"
205+
206+
# R-04: a client-supplied x-llm-client-id must never influence authorization.
207+
check "spoof: A's key + x-llm-client-id=B -> model B" \
208+
403 "$(req "$SERVE_B" "$KEY_A" -H "x-llm-client-id: ${CID_B}")"
209+
check "spoof: A's key + duplicate x-llm-client-id=B -> model B" \
210+
403 "$(req "$SERVE_B" "$KEY_A" -H "x-llm-client-id: ${CID_B}" -H "x-llm-client-id: ${CID_B}")"
211+
check "spoof: A's key + mixed-case X-LLM-CLIENT-ID=B -> model B" \
212+
403 "$(req "$SERVE_B" "$KEY_A" -H "X-LLM-CLIENT-ID: ${CID_B}")"
213+
check "spoof: A's key + comma-joined x-llm-client-id=A,B -> model B" \
214+
403 "$(req "$SERVE_B" "$KEY_A" -H "x-llm-client-id: ${CID_A},${CID_B}")"
215+
216+
echo
217+
if [[ "$FAILED" -eq 0 ]]; then
218+
echo "==> PASS: per-model API-key scoping holds and x-llm-client-id cannot be spoofed."
219+
else
220+
echo "==> FAIL: see assertions above." >&2
221+
fi
222+
exit "$FAILED"

0 commit comments

Comments
 (0)