Skip to content

feat(access): per-api-key model allowlist with glob matching#2905

Open
mtuckerb wants to merge 1 commit into
router-for-me:devfrom
mtuckerb:feat/api-key-allowed-models
Open

feat(access): per-api-key model allowlist with glob matching#2905
mtuckerb wants to merge 1 commit into
router-for-me:devfrom
mtuckerb:feat/api-key-allowed-models

Conversation

@mtuckerb
Copy link
Copy Markdown

Summary

Adds enforcement for per-client-key model ACLs at the request middleware layer. The companion dashboard already stores allowedModels per UserApiKey and attempts to push them to PUT /v0/management/api-keys, but until now there was nowhere upstream for them to land — the structured payload was rejected and dashboards silently fell back to the legacy plain-array form, so no key was ever actually restricted.

This change adds:

Config

  • APIKeyPolicy{Key, AllowedModels} and SDKConfig.APIKeyPolicies
  • SDKConfig.APIKeyDefaultPolicy ("allow-all" | "deny-all"; defaults to allow-all so existing deployments are unaffected)
  • IsModelAllowedForKey supports path.Match globs and tolerates a single leading <prefix>/ segment so policies stay portable across prefixed credentials

Management API

  • PutAPIKeys accepts both the legacy string list and the structured [{key, allowedModels}] form, with allowedModels / allowed-models / allowed_models all recognized so existing dashboard payloads work unchanged
  • GetAPIKeys exposes api-key-policies when any are configured

Middleware

  • New ModelACLMiddleware extracts the targeted model from the JSON body (model field) or the Gemini-style /v1beta/models/<model>:<action> path, then enforces the per-key policy
  • The body is read once and restored via io.NopCloser so downstream handlers still see the original request bytes
  • Wired into both /v1 and /v1beta groups directly after AuthMiddleware
  • Bypasses any request that has no identified api key (preserving the legacy "no auth provider configured => allow" behavior) and any shape with no extractable model (listings, websocket upgrades) so discovery endpoints keep working

Test plan

  • Unit tests for IsModelAllowedForKey covering allow-all, deny-all, empty allowed list semantics, exact matches, glob matches, and the prefixed-credential case
  • Middleware tests covering the allow / deny / unknown-key / Gemini path / list-endpoint / body-pass-through paths
  • Parser tests for PutAPIKeys covering the legacy plain list, the wrapped items form, and the structured form in all three case styles (allowedModels / allowed-models / allowed_models)

Backwards compatibility

Default policy is allow-all; deployments that do not opt in by setting APIKeyPolicies or flipping APIKeyDefaultPolicy to deny-all see no behavior change. Existing dashboards that PUT a plain string list continue to work — the new structured form is additive.

Adds enforcement for per-client-key model ACLs at the request middleware
layer. The dashboard already stores allowedModels per UserApiKey and
attempts to push them to PUT /v0/management/api-keys, but upstream until
now had nowhere to land them — the structured payload was rejected and
the dashboard's fallback silently dropped the policy data, so no key was
ever actually restricted.

config:
- Add APIKeyPolicy{Key, AllowedModels} and SDKConfig.APIKeyPolicies
- Add SDKConfig.APIKeyDefaultPolicy ("allow-all" | "deny-all"; defaults
  to allow-all so existing deployments are unaffected)
- IsModelAllowedForKey supports path.Match globs and tolerates a single
  leading "<prefix>/" segment so policies stay portable across prefixed
  credentials

management API:
- PutAPIKeys now accepts both the legacy string list and the structured
  [{key, allowedModels}] form, with allowedModels / allowed-models /
  allowed_models all recognized so existing dashboard payloads work
- GetAPIKeys exposes api-key-policies when any are configured

middleware:
- New ModelACLMiddleware extracts the targeted model from the JSON body
  ("model" field) or the Gemini-style /v1beta/models/<model>:<action>
  path, then enforces the per-key policy. The body is read once and
  restored via io.NopCloser so downstream handlers see the original
  request bytes
- Wired into both /v1 and /v1beta groups directly after AuthMiddleware
- Bypasses any request that has no identified api key (preserving the
  legacy "no auth provider configured => allow" behavior of
  AuthMiddleware) and any request shape with no extractable model
  (listings, websocket upgrades) so discovery endpoints keep working

tests:
- Unit tests for IsModelAllowedForKey covering allow-all, deny-all,
  empty allowed list semantics, exact matches, glob matches, and the
  prefixed-credential case
- Middleware tests covering the allow / deny / unknown-key / Gemini
  path / list-endpoint / body-pass-through paths
- Parser tests for the management PutAPIKeys payload covering the
  legacy plain list, the wrapped items form, and the structured form
  in all three case styles
@github-actions
Copy link
Copy Markdown

This pull request targeted main.

The base branch has been automatically changed to dev.

@github-actions github-actions Bot changed the base branch from main to dev April 19, 2026 14:03
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a model Access Control List (ACL) system to restrict API keys to specific model identifiers using glob patterns. Key changes include the implementation of ModelACLMiddleware for enforcing permissions across OpenAI and Gemini-compatible routes, updates to the management API to support structured key-policy payloads, and new configuration logic for glob-based model matching. Review feedback identifies a critical data race in the API key update handler, a potential memory exhaustion vulnerability when reading request bodies without a size limit, and a performance optimization opportunity to use map-based lookups for key policies.

Comment on lines +142 to +144
h.cfg.APIKeys = append([]string(nil), keys...)
h.cfg.APIKeyPolicies = append([]config.APIKeyPolicy(nil), policies...)
h.persist(c)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The PutAPIKeys method modifies h.cfg.APIKeys and h.cfg.APIKeyPolicies in-place without acquiring the handler's mutex (h.mu). Since the configuration is shared with the main server and accessed by the ModelACLMiddleware on every request, this creates a data race. You should wrap these modifications and the persistence call in a lock, using h.persistLocked(c) to ensure thread safety and avoid deadlocks.

	h.mu.Lock()
	defer h.mu.Unlock()
	h.cfg.APIKeys = append([]string(nil), keys...)
	h.cfg.APIKeyPolicies = append([]config.APIKeyPolicy(nil), policies...)
	h.persistLocked(c)

Comment thread internal/api/model_acl.go
Comment on lines +122 to +125
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return "", false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Reading the entire request body into memory using io.ReadAll without a size limit is risky and can lead to memory exhaustion (OOM) if a client sends a very large request. Additionally, if io.ReadAll fails, the function returns without restoring the request body, leaving it in a broken state for downstream handlers. Consider using http.MaxBytesReader to enforce a reasonable limit and ensure the body is handled safely.

Comment on lines +94 to +97
for i := range c.APIKeyPolicies {
if c.APIKeyPolicies[i].Key != trimmedKey {
continue
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This loop performs a linear search through APIKeyPolicies on every request. While acceptable for a small number of keys, it will become a performance bottleneck as the number of policies grows. Consider pre-indexing these policies into a map (e.g., map[string]APIKeyPolicy) when the configuration is loaded or updated to allow for O(1) lookups.

Copy link
Copy Markdown
Collaborator

@luispater luispater left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds per-API-key model allowlists (config + management API) and enforces them via ModelACLMiddleware on /v1 + /v1beta, with DoS hardening (bounded body inspection) and an atomic cached policy index. Test coverage is substantial and generally well-targeted.

Blocking

  • Bypass via /backend-api/codex aliases: The ACL middleware is wired into /v1 and /v1beta only. The /backend-api/codex group (chatgpt_base_url compatible) also uses AuthMiddleware(...) but does not use ModelACLMiddleware(...), so restricted keys can reach /responses (including websocket upgrades) without allowlist enforcement.

    • Suggested fix: add codexDirect.Use(ModelACLMiddleware(cfgFn)) immediately after codexDirect.Use(AuthMiddleware(...)) (and consider parity for any other AuthMiddleware-wrapped groups).
    • Suggested regression guard: a focused route-setup/integration test proving the alias path is protected when policies are configured.
  • Websocket restriction edge case under deny-all default: keyHasModelRestriction() returns false when a matching policy row exists but AllowedModels is empty, even if api-key-default-policy=deny-all. That makes the websocket-upgrade path potentially more permissive than the JSON-body path in this configuration.

    • Suggested fix: treat “matching policy row with empty AllowedModels” as restricted when the default is deny-all, consistent with IsModelAllowedForKey semantics; add a test for this edge case.

Non-blocking

  • Management API response shape: PUT accepts allowedModels/allowed-models/allowed_models, but GET will emit allowed-models due to struct tags. Consider documenting this or emitting camelCase to match dashboard payload expectations.
  • parseAPIKeysPayload clears via [] but rejects {"items":[]}; consider allowing wrapped-empty if any clients clear through the wrapped form.
  • SetAPIKeyPolicies copies the policy slice but not nested AllowedModels slices; consider deep-copying if there’s any chance of post-call mutation of the input.

Test plan

  • go test ./...
  • Add/extend tests to cover /backend-api/codex alias enforcement and the deny-all + empty-AllowedModels websocket case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants