feat(access): per-api-key model allowlist with glob matching#2905
feat(access): per-api-key model allowlist with glob matching#2905mtuckerb wants to merge 1 commit into
Conversation
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
|
This pull request targeted The base branch has been automatically changed to |
There was a problem hiding this comment.
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.
| h.cfg.APIKeys = append([]string(nil), keys...) | ||
| h.cfg.APIKeyPolicies = append([]config.APIKeyPolicy(nil), policies...) | ||
| h.persist(c) |
There was a problem hiding this comment.
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)| bodyBytes, err := io.ReadAll(c.Request.Body) | ||
| if err != nil { | ||
| return "", false | ||
| } |
There was a problem hiding this comment.
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.
| for i := range c.APIKeyPolicies { | ||
| if c.APIKeyPolicies[i].Key != trimmedKey { | ||
| continue | ||
| } |
There was a problem hiding this comment.
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.
luispater
left a comment
There was a problem hiding this comment.
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/codexaliases: The ACL middleware is wired into/v1and/v1betaonly. The/backend-api/codexgroup (chatgpt_base_url compatible) also usesAuthMiddleware(...)but does not useModelACLMiddleware(...), so restricted keys can reach/responses(including websocket upgrades) without allowlist enforcement.- Suggested fix: add
codexDirect.Use(ModelACLMiddleware(cfgFn))immediately aftercodexDirect.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.
- Suggested fix: add
-
Websocket restriction edge case under deny-all default:
keyHasModelRestriction()returnsfalsewhen a matching policy row exists butAllowedModelsis empty, even ifapi-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
IsModelAllowedForKeysemantics; add a test for this edge case.
- Suggested fix: treat “matching policy row with empty AllowedModels” as restricted when the default is deny-all, consistent with
Non-blocking
- Management API response shape: PUT accepts
allowedModels/allowed-models/allowed_models, but GET will emitallowed-modelsdue to struct tags. Consider documenting this or emitting camelCase to match dashboard payload expectations. parseAPIKeysPayloadclears via[]but rejects{"items":[]}; consider allowing wrapped-empty if any clients clear through the wrapped form.SetAPIKeyPoliciescopies the policy slice but not nestedAllowedModelsslices; 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/codexalias enforcement and the deny-all + empty-AllowedModels websocket case.
Summary
Adds enforcement for per-client-key model ACLs at the request middleware layer. The companion dashboard already stores
allowedModelsper UserApiKey and attempts to push them toPUT /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}andSDKConfig.APIKeyPoliciesSDKConfig.APIKeyDefaultPolicy("allow-all"|"deny-all"; defaults toallow-allso existing deployments are unaffected)IsModelAllowedForKeysupportspath.Matchglobs and tolerates a single leading<prefix>/segment so policies stay portable across prefixed credentialsManagement API
PutAPIKeysaccepts both the legacy string list and the structured[{key, allowedModels}]form, withallowedModels/allowed-models/allowed_modelsall recognized so existing dashboard payloads work unchangedGetAPIKeysexposesapi-key-policieswhen any are configuredMiddleware
ModelACLMiddlewareextracts the targeted model from the JSON body (modelfield) or the Gemini-style/v1beta/models/<model>:<action>path, then enforces the per-key policyio.NopCloserso downstream handlers still see the original request bytes/v1and/v1betagroups directly afterAuthMiddlewareTest plan
IsModelAllowedForKeycovering allow-all, deny-all, empty allowed list semantics, exact matches, glob matches, and the prefixed-credential casePutAPIKeyscovering 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 settingAPIKeyPoliciesor flippingAPIKeyDefaultPolicytodeny-allsee no behavior change. Existing dashboards thatPUTa plain string list continue to work — the new structured form is additive.