Skip to content

Commit 7e02117

Browse files
jrhynessclaude
andcommitted
feat: remove identity headers from model inference AuthPolicies
Remove X-MaaS-Username, X-MaaS-Group, X-MaaS-Key-Id, and X-MaaS-Subscription headers from requests forwarded to upstream model workloads (defense-in-depth). All identity information remains available to TokenRateLimitPolicy and gateway telemetry through AuthPolicy's filters.identity mechanism, but is not exposed to model runtime pods to prevent accidental disclosure in logs or dumps. Changes: - Remove response.success.headers from controller-generated AuthPolicies - Add test verifying headers not forwarded and identity data available - Update docs to explain security model and scope (model routes only) Out of scope: maas-api static AuthPolicy unchanged (still uses headers for ExtractUserInfo middleware on maas-api-route). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4f06bcf commit 7e02117

File tree

4 files changed

+142
-38
lines changed

4 files changed

+142
-38
lines changed

docs/content/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ The MaaSAuthPolicy delegates to the MaaS API for key validation and subscription
214214
2. MaaS API validates the key (format, not revoked, not expired) and returns username, groups, and subscription.
215215
3. Authorino calls MaaS API to check subscription (groups, username, requested subscription from the key).
216216
4. If the user lacks access to the requested subscription → error (403).
217-
5. On success, returns selected subscription; Authorino caches the result (e.g., 60s TTL). AuthPolicy may inject `X-MaaS-Subscription` **server-side** for downstream rate limiting and metrics. Clients do not send this header on inference; subscription comes from the API key record created at mint time.
217+
5. On success, returns selected subscription; Authorino caches the result (e.g., 60s TTL). Identity information (username, groups, subscription, key ID) is made available to TokenRateLimitPolicy and observability through AuthPolicy's `filters.identity` mechanism, but is **not forwarded** as HTTP headers to upstream model workloads (defense-in-depth security). Clients do not send subscription headers on inference; subscription comes from the API key record created at mint time.
218218

219219
```mermaid
220220
graph TB

docs/content/configuration-and-management/maas-controller-overview.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,32 @@ The Kuadrant AuthPolicy validates API keys via the MaaS API and validates user t
231231

232232
---
233233

234+
## 9.1. Identity Headers and Defense-in-Depth
235+
236+
**For model inference routes** (HTTPRoutes targeting model workloads):
237+
238+
The controller-generated AuthPolicies do **not** inject identity-related HTTP headers (`X-MaaS-Username`, `X-MaaS-Group`, `X-MaaS-Key-Id`, `X-MaaS-Subscription`) into requests forwarded to upstream model pods. This is a defense-in-depth security measure to prevent accidental disclosure of user identity, group membership, and key identifiers in:
239+
240+
- Model runtime logs
241+
- Upstream debug dumps
242+
- Misconfigured proxies or sidecars
243+
244+
All identity information remains available to **gateway-level features** through Authorino's `auth.identity` and `auth.metadata` contexts, which are consumed by:
245+
246+
- **TokenRateLimitPolicy (TRLP)**: Uses `selected_subscription_key`, `userid`, `groups`, and `subscription_labels` from `filters.identity`
247+
- **Gateway telemetry/metrics**: Accesses identity fields with `metrics: true` enabled on `filters.identity`
248+
- **Authorization policies**: OPA/Rego rules evaluate `auth.identity` and `auth.metadata` directly
249+
250+
**For maas-api routes**:
251+
252+
The static AuthPolicy for maas-api (`deployment/base/maas-api/policies/auth-policy.yaml`) still injects `X-MaaS-Username` and `X-MaaS-Group` headers, as maas-api's `ExtractUserInfo` middleware requires them. This is separate from model inference routes and follows a different security model (maas-api is a trusted internal service).
253+
254+
**Security motivation:**
255+
256+
Model workloads (vLLM, Llama.cpp, etc.) do not require strong identity claims in cleartext headers. By keeping identity at the gateway layer, we reduce the attack surface and limit the blast radius of potential log leaks or upstream vulnerabilities.
257+
258+
---
259+
234260
## 10. Summary
235261

236262
| Topic | Summary |

maas-controller/pkg/controller/maas/maasauthpolicy_controller.go

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -448,45 +448,12 @@ allow {
448448
// match against subscription groups (which may differ from auth policy groups).
449449
// Also inject subscription metadata from subscription-info for Limitador metrics.
450450
// For API keys: username/groups come from apiKeyValidation metadata
451-
// For K8s tokens: username/groups come from auth.identity
451+
// Identity headers intentionally removed for defense-in-depth:
452+
// User identity, groups, and key IDs are not forwarded to upstream model workloads
453+
// to prevent accidental disclosure in logs or dumps. All identity information remains
454+
// available to TRLP and telemetry via auth.identity and filters.identity below.
452455
rule["response"] = map[string]interface{}{
453456
"success": map[string]interface{}{
454-
"headers": map[string]interface{}{
455-
// Username from API key validation or K8s token identity
456-
"X-MaaS-Username": map[string]interface{}{
457-
"plain": map[string]interface{}{
458-
"expression": `(has(auth.metadata) && has(auth.metadata.apiKeyValidation)) ? auth.metadata.apiKeyValidation.username : auth.identity.user.username`,
459-
},
460-
"metrics": false,
461-
"priority": int64(0),
462-
},
463-
// Groups - serialize to JSON array string from API key validation or K8s identity
464-
// Using string() conversion of JSON-serialized groups for proper escaping
465-
"X-MaaS-Group": map[string]interface{}{
466-
"plain": map[string]interface{}{
467-
"expression": `string(((has(auth.metadata) && has(auth.metadata.apiKeyValidation)) ? auth.metadata.apiKeyValidation.groups : auth.identity.user.groups))`,
468-
},
469-
"metrics": false,
470-
"priority": int64(0),
471-
},
472-
// Key ID for tracking (only for API keys)
473-
"X-MaaS-Key-Id": map[string]interface{}{
474-
"plain": map[string]interface{}{
475-
"expression": `(has(auth.metadata) && has(auth.metadata.apiKeyValidation)) ? auth.metadata.apiKeyValidation.keyId : ""`,
476-
},
477-
"metrics": false,
478-
"priority": int64(0),
479-
},
480-
// Subscription bound to API key (only for API keys)
481-
// For K8s tokens, this header is not injected (empty string)
482-
"X-MaaS-Subscription": map[string]interface{}{
483-
"plain": map[string]interface{}{
484-
"expression": `(has(auth.metadata) && has(auth.metadata.apiKeyValidation)) ? auth.metadata.apiKeyValidation.subscription : ""`,
485-
},
486-
"metrics": false,
487-
"priority": int64(0),
488-
},
489-
},
490457
"filters": map[string]interface{}{
491458
"identity": map[string]interface{}{
492459
"json": map[string]interface{}{

maas-controller/pkg/controller/maas/maasauthpolicy_controller_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,117 @@ func TestMaaSAuthPolicyReconciler_CacheKeyModelIsolation(t *testing.T) {
10621062
}
10631063
}
10641064

1065+
// TestMaaSAuthPolicyReconciler_NoIdentityHeadersUpstream verifies that identity headers
1066+
// (X-MaaS-Username, X-MaaS-Group, X-MaaS-Key-Id, X-MaaS-Subscription) are NOT injected
1067+
// into requests forwarded to upstream model workloads (defense-in-depth).
1068+
// All identity information remains available to TRLP and telemetry via filters.identity.
1069+
func TestMaaSAuthPolicyReconciler_NoIdentityHeadersUpstream(t *testing.T) {
1070+
const (
1071+
modelName = "llm"
1072+
namespace = "default"
1073+
httpRouteName = "maas-model-" + modelName
1074+
authPolicyName = "maas-auth-" + modelName
1075+
maasPolicyName = "policy-a"
1076+
)
1077+
1078+
model := newMaaSModelRef(modelName, namespace, "ExternalModel", modelName)
1079+
route := newHTTPRoute(httpRouteName, namespace)
1080+
maasPolicy := newMaaSAuthPolicy(maasPolicyName, namespace, "team-a", maasv1alpha1.ModelRef{Name: modelName, Namespace: namespace})
1081+
1082+
c := fake.NewClientBuilder().
1083+
WithScheme(scheme).
1084+
WithRESTMapper(testRESTMapper()).
1085+
WithObjects(model, route, maasPolicy).
1086+
WithStatusSubresource(&maasv1alpha1.MaaSAuthPolicy{}).
1087+
Build()
1088+
1089+
r := &MaaSAuthPolicyReconciler{
1090+
Client: c,
1091+
Scheme: scheme,
1092+
MaaSAPINamespace: "maas-system",
1093+
MetadataCacheTTL: 60,
1094+
AuthzCacheTTL: 60,
1095+
}
1096+
1097+
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: maasPolicyName, Namespace: namespace}}
1098+
if _, err := r.Reconcile(context.Background(), req); err != nil {
1099+
t.Fatalf("Reconcile: unexpected error: %v", err)
1100+
}
1101+
1102+
got := &unstructured.Unstructured{}
1103+
got.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
1104+
if err := c.Get(context.Background(), types.NamespacedName{Name: authPolicyName, Namespace: namespace}, got); err != nil {
1105+
t.Fatalf("Get AuthPolicy %q: %v", authPolicyName, err)
1106+
}
1107+
1108+
// Test 1: Verify response.success.headers does NOT exist (identity headers not forwarded upstream)
1109+
t.Run("identity headers not forwarded to upstream", func(t *testing.T) {
1110+
headers, found, err := unstructured.NestedMap(got.Object, "spec", "rules", "response", "success", "headers")
1111+
if err != nil {
1112+
t.Fatalf("Error checking headers: %v", err)
1113+
}
1114+
1115+
// Headers should either not exist or be empty
1116+
if found && len(headers) > 0 {
1117+
// Check that identity headers specifically are not present
1118+
forbiddenHeaders := []string{"X-MaaS-Username", "X-MaaS-Group", "X-MaaS-Key-Id", "X-MaaS-Subscription"}
1119+
for _, header := range forbiddenHeaders {
1120+
if _, exists := headers[header]; exists {
1121+
t.Errorf("Identity header %q should NOT be forwarded to upstream workloads (defense-in-depth)", header)
1122+
}
1123+
}
1124+
}
1125+
})
1126+
1127+
// Test 2: Verify filters.identity exists and contains all necessary data for TRLP and telemetry
1128+
t.Run("identity data available for TRLP and telemetry", func(t *testing.T) {
1129+
identity, found, err := unstructured.NestedMap(got.Object, "spec", "rules", "response", "success", "filters", "identity", "json", "properties")
1130+
if err != nil || !found {
1131+
t.Fatalf("filters.identity.json.properties missing: found=%v err=%v", found, err)
1132+
}
1133+
1134+
// Verify essential fields for TRLP
1135+
requiredFields := []string{
1136+
"userid", // User identification
1137+
"groups", // User groups
1138+
"selected_subscription_key", // Model-scoped subscription key for TRLP
1139+
"selected_subscription", // Subscription name
1140+
"subscription_labels", // Labels for rate limit tiers
1141+
}
1142+
1143+
for _, field := range requiredFields {
1144+
if _, exists := identity[field]; !exists {
1145+
t.Errorf("filters.identity must include %q for TRLP/telemetry, but it's missing", field)
1146+
}
1147+
}
1148+
1149+
// Verify telemetry/observability fields
1150+
observabilityFields := []string{
1151+
"keyId", // API key tracking
1152+
"organizationId", // Cost attribution
1153+
"costCenter", // Chargeback
1154+
}
1155+
1156+
for _, field := range observabilityFields {
1157+
if _, exists := identity[field]; !exists {
1158+
t.Errorf("filters.identity should include %q for observability, but it's missing", field)
1159+
}
1160+
}
1161+
})
1162+
1163+
// Test 3: Verify metrics are enabled on identity filter
1164+
t.Run("identity filter has metrics enabled", func(t *testing.T) {
1165+
metricsEnabled, found, err := unstructured.NestedBool(got.Object, "spec", "rules", "response", "success", "filters", "identity", "metrics")
1166+
if err != nil || !found {
1167+
t.Fatalf("filters.identity.metrics missing: found=%v err=%v", found, err)
1168+
}
1169+
1170+
if !metricsEnabled {
1171+
t.Error("filters.identity.metrics must be true for telemetry to access identity data")
1172+
}
1173+
})
1174+
}
1175+
10651176
// contains is a helper to check if a string contains a substring (case-sensitive).
10661177
func contains(s, substr string) bool {
10671178
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||

0 commit comments

Comments
 (0)