Skip to content

fix(operator): scope API keys per-model via per-route authorization (#116)#117

Open
dcmcand wants to merge 5 commits into
feat/local-dev-passthrough-and-ui-devmodefrom
fix/116-model-scoped-api-key-auth
Open

fix(operator): scope API keys per-model via per-route authorization (#116)#117
dcmcand wants to merge 5 commits into
feat/local-dev-passthrough-and-ui-devmodefrom
fix/116-model-scoped-api-key-auth

Conversation

@dcmcand

@dcmcand dcmcand commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What this fixes

Closes #116. Today any valid API key works for every model on the shared external gateway listener (llm.<baseDomain>). Envoy Gateway pools the apiKeyAuth credentials from every per-model SecurityPolicy on that listener into one set, so a key minted for a cheap model authenticates against an expensive or restricted one just by changing the model field. Group checks at mint time still hold, but there was no request-time per-model enforcement on the API-key path.

Approach

The isolation belongs in authorization, not authentication. Each model's external SecurityPolicy now gets:

  • apiKeyAuth.forwardClientIDHeader: x-llm-client-id and sanitize: true
  • an authorization block: defaultAction: Deny plus one Allow rule listing only the client IDs present in that model's own api-keys Secret

A foreign key still passes the pooled authentication, but it forwards a client ID that is not in the target route's allow-list, so authorization returns 403. The single shared listener and single TLS certificate are unchanged.

The operator reads each model's api-keys Secret to build the allow-list and watches that Secret (label-filtered, no owner reference) so minting or revoking a key re-renders the policy within about a minute. Both served (LLMModel) and passthrough (PassthroughModel) models are covered through the shared builder.

Note on the Envoy Gateway version

The auth.go TODO claimed forwardClientIDHeader/sanitize need EG v1.7+. That is not correct: both fields exist as of EG v1.5.7 / v1.6.0, and Authorization.Principal.Headers (exact header matching) is available too. The dev stack is already on EG v1.6.7 (via #115), and AI Gateway v0.5 already forces an EG v1.6.x floor, so this needs no version bump. The stale TODO is removed.

Behavior

With a key minted only into model A's Secret:

  • A's key against model A: 200
  • A's key against model B: 403 (was 200)
  • invalid key against any model: 401 (authentication still active)
  • a model with an empty Secret, called with another model's valid key: 403

Blocked by #115

This is stacked on #115 (which aligns the dev stack to EG v1.6.7). The PR is based on feat/local-dev-passthrough-and-ui-devmode so the diff shows only this change; it should merge after #115. GitHub will retarget it to main once #115 lands.

Test plan

Automated (passing):

  • buildExternalSecurityPolicy / passthrough builder render forwardClientIDHeader, sanitize: true, and authorization: defaultAction: Deny
  • the Allow rule lists exactly the model's own client IDs and excludes a foreign client ID
  • zero client IDs render deny-all with no Allow rule (the empty-Secret case)
  • both LLMModel and PassthroughModel paths covered
  • the internal/JWT authorization path is unchanged and still passing

Deferred (needs the EG v1.6.7 dev stack from #115, so not yet run): the end-to-end curl matrix on a live cluster (the 200/403/401 cases above, plus revoke and fresh-mint timing). This is tracked and will be run once #115 merges and the stack is available.

Docs in docs/design.md are updated to describe the per-model authorization and to correct a stale claim that per-model hostnames enforced access control.

dcmcand added 4 commits June 26, 2026 15:37
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
Watch managed api-keys Secrets and enqueue the owning model so minting or
revoking a key promptly updates the per-route authorization allow-list.
@dcmcand dcmcand added the status: blocked ⛔️ Another task is blocking this item label Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: blocked ⛔️ Another task is blocking this item

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant