Skip to content

[Feature]: Per-admission-key allowlist for models and providers #5049

Closed

Description

@nordz0r

Area

Multiple areas

What are you trying to accomplish?

I run a centrally hosted OpenCodex hub used by several clients (Hermes profiles, OMP, Open WebUI, connect clients). Each client authenticates with its own admission API key (ocx_data_…). I need to restrict which models and which providers a given key may call, so a mail/cron key cannot spend Grok/Claude quota, and a coding-agent key cannot see the full hub catalog.

Today the published /v1/models catalog and provider routing are hub-global. Any valid admission key can request any published model (gldf-flash, xai/grok-4.6, combos, …).

What prevents this today?

GET /api/keys (and ocx access key list --json) returns only identity + request counters:

  • id, name, prefix, createdAt
  • usage.requests7d, usage.totalRequests, usage.lastUsedAt

There is no per-key allowlist for models or providers. Hub-level models selected / combos / routing profiles apply to every key. Related roadmap #95 covers tenant isolation for a multi-user hub; this request is the narrower admission-key ACL that a single-operator hub needs without full tenancy.

Workaround today: separate hubs or “don’t give that client the URL” — not viable when one hub serves many keys.

What should OpenCodex do?

For each admission API key, allow an optional allowlist of:

  • providers (e.g. zai-discount, openrouter, combo)
  • models / combo aliases (e.g. gldf-flash, google-antigravity/gemini-3.8-flash)

Observable behaviour:

  1. Empty / unset allowlist = current behaviour (full published catalog).
  2. Non-empty allowlist:
    • GET /v1/models with that key returns only allowed ids (including combo aliases).
    • POST /v1/chat/completions (and /v1/responses, /v1/messages) for a non-allowed model returns 403 with a stable error (model_not_allowed_for_key), not a silent remap to another model.
  3. Management UI (Dashboard → API keys) can set the lists without rotating the secret.
  4. CLI can inspect and set them without printing the plaintext secret:
ocx access key get <id-or-name> --json
ocx access key set <id-or-name> --allow-provider zai-discount --allow-model gldf-flash
  1. GET /api/keys includes the allowlists (no secret). Denied requests are visible in ocx logs with the key name, requested model, and model_not_allowed_for_key.

Example usage or interface

Dashboard: API Keys → key hermes-mail-monitor → “Allowed providers / models”:

  • providers: zai-discount
  • models: gldf-flash, combo/free
# inspect (no secret in output)
ocx access key get hermes-mail-monitor --json
# {
#   "id": "9b5b3702-…",
#   "name": "hermes-mail-monitor",
#   "allowedProviders": ["zai-discount"],
#   "allowedModels": ["gldf-flash", "combo/free"]
# }

# data plane
curl -sS https://hub.example/v1/models \
  -H "Authorization: Bearer $MAIL_KEY"
# only gldf-flash and combo/free

curl -sS https://hub.example/v1/chat/completions \
  -H "Authorization: Bearer $MAIL_KEY" \
  -d '{"model":"xai/grok-4.6","messages":[{"role":"user","content":"hi"}]}'
# HTTP 403 {"error":{"type":"model_not_allowed_for_key","model":"xai/grok-4.6"}}

A coding-agent key (hermes-nord) would keep an empty allowlist or an explicit list that includes gldf-hermes, xai/grok-4.6, etc.

Alternatives or workarounds

  • Separate OpenCodex processes / hubs per client — operationally heavy (state, OAuth pools, image).
  • Rely on the client not to send expensive models — clients (Hermes /model picker, OMP catalog inject) discover the full hub catalog.
  • Hub-global models selected — hides a model from everyone, not from one key.
  • [Roadmap] Centrally hosted multi-user OpenCodex with tenant isolation #95 tenant isolation — the right long-term shape; this ACL is a smaller step that unblocks single-operator multi-key hubs without tenancy.

Not requesting spend quotas or USD caps on keys in this issue (usage today has cost per upstream account / provider, not per admission key). Catalog ACL is the missing control.

Additional context

Verified on OpenCodex 2.57.0 (self-hosted hub):

  • GET /api/keys fields listed above — no model/provider ACL.
  • ocx usage --json attributes estimatedCostUsd to providers and accountLogLabel, not to the admission key.
  • Zabbix LLD around /api/keys can only graph requests_7d / requests_total per key name.

Related: #95 (tenant isolation). Happy to split Dashboard vs CLI vs data-plane 403 into follow-ups if that helps landing.

Checks

  • I searched existing issues and documentation.
  • This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
  • I removed secrets and personal data.

Activity

  1. Ingwannu commented on Sep 18, 2026

    @Ingwannu
    Owner

    Confirmed: this is a real, focused authorization gap and a reasonable first slice under #95. Configured admission already derives a trusted apiKeyId by comparing a server-held secret, but that identity is currently attribution only; it carries no model/provider policy.

    The implementation should land catalog projection and request enforcement from one immutable admission-derived policy in the same change. Filtering /v1/models without denying the corresponding send would create a misleading visibility-only promise, while denying sends without filtering would advertise unusable rows.

    Required design constraints:

    1. Policy is keyed by the configured API-key id, never by a caller-supplied header, key name or plaintext secret.
    2. Legacy keys with no policy remain unrestricted. Define explicit semantics for an empty policy separately; do not let parse failure silently become unrestricted.
    3. Store canonical provider ids and canonical selectors, not display labels.
    4. Apply before routing and again to the resolved route closure: aliases, account-qualified rows, synthetic Fast/effort rows, policies, fallbacks and combos must not escape through a permitted display selector into a forbidden provider.
    5. An explicitly allowed combo needs a documented rule for its members. Either the alias grants its complete reviewed member closure, or every possible member must also be allowed; never silently choose whichever member passes.
    6. Enforce identically on Responses, Chat Completions, Claude Messages and remote catalog reads. A denial is a stable 403 and never a remap.
    7. Management reads remain operator-scoped. Any future tenant-facing history/usage endpoint must override caller query filters with the authenticated key scope rather than trusting apiKeyId from the URL.
    8. Logs may record the bounded key id/name and requested selector, but never the secret or raw Authorization value.

    Negative tests should come first: cross-key catalog isolation, direct forbidden send, alias/combo/fallback escape attempts, credential non-forwarding, policy mutation without secret rotation, and legacy-unrestricted compatibility. This does not make the proxy fully multi-tenant: account pools, affinity, usage, routing state and concurrency remain process-global until the broader #95 design is completed.

    Keeping this open as an accepted design/implementation issue; no current PR is linked yet.

  2. lidge-jun commented on Sep 19, 2026

    @lidge-jun
    Owner

    Implementation-path refinement from source review at 7864869c31c41cca9830d93540238f17df8faafb. This issue already owns the narrow per-admission-key authorization gap; no duplicate proposal is needed.

    • Extend OcxApiKeyEntry in src/types/config.ts:245 with optional policy fields and wire their full chain: config/management input validation, persistence, reload/default handling, key rename/rotation preservation, management/CLI projection, catalog projection and request consumers.
    • Reuse the authenticated key identity from src/server/auth-cors.ts:395; caller-supplied informational identity headers are not policy authority. Keep management credentials independent.
    • Evaluate policy against canonical resolved provider/model identity, then re-evaluate after fallback/combo route changes. src/server/responses/request-prepare.ts:404 is one repeated resolution point; inventory HTTP, WebSocket, translated inbound, combo and retry entrypoints before implementation.
    • Define aliases explicitly: an allowed combo alias must not implicitly authorize an otherwise forbidden resolved destination. Catalog filtering and actual admission must consume the same policy evaluator.
    • Refuse before upstream I/O, preserve key-scoped hub usage (src/server/hub-usage.ts:8), and expose bounded denial metadata without secret material.

    Acceptance fixtures: unset policy preserves current behavior; allowed/denied direct models; aliases; fallback and combo re-resolution; key rotation/rename; invalid policy input; data-vs-management auth separation; and zero physical sends on denial. Register new tests in both layout manifests, update config/catalog/management/Responses structure owners, and document operator behavior. Broader tenant roles and administration remain in #95. No runtime tests or live account calls were performed for this analysis.

  3. added a commit that references this issue on Sep 20, 2026
    447ac22
  4. lidge-jun commented on Sep 20, 2026

    @lidge-jun
    Owner

    Partially implemented on dev by #5265 (447ac22ca65baabd573dbd429abe28ed9deff594). Keeping this open for the remaining surface.

    What landed: a per-admission-key scope over models and providers, evaluated against the resolved destination rather than the client's string, because alias resolution, policy and combo selection, subagent fallback and compaction override all rewrite that string. On the Responses path every produced route — direct name, alias, policy, combo child, shadow-intercept target and both subagent-fallback re-routes — passes one capture point where the check sits. Chat and Messages translate into that path; their native lanes and the compaction route apply the same predicate themselves. /v1/models filters by the same predicate and is explicitly not the boundary. A malformed scope drops the key instead of degrading to unrestricted.

    What is still uncovered, found by adversarial review of the branch: authenticated data-plane endpoints that spend provider quota without resolving a model through the router are not reached by the scope.

    • /v1/images/generations and /v1/images/edits
    • /v1/audio/transcriptions and its streaming form
    • /v1/live, /v1/realtime/calls and the standalone realtime sockets
    • the non-account-qualified branch of /v1/alpha/search, which forwards the caller's model to a search sidecar without routing it

    Budgets and request or token rate limits remain deliberately out of scope here.

  5. lidge-jun commented on Sep 20, 2026

    @lidge-jun
    Owner

    Closed on dev. The scope is now applied at every path that spends provider quota.

    Two landings complete it. #5265 (447ac22ca65baabd573dbd429abe28ed9deff594) put the check on the resolved destination rather than the client's string, because alias resolution, policy and combo selection, subagent fallback and compaction override all rewrite that string. On the Responses path every produced route — direct name, alias, policy, combo child, shadow-intercept target and both subagent-fallback re-routes — passes one capture point where the predicate sits; Chat and Messages translate into that path, and their native lanes and the compaction route apply the same predicate themselves. /v1/models filters by the same predicate and is explicitly not the boundary.

    An adversarial review of that branch found the gap that kept this open: authenticated data-plane endpoints that spend provider quota without resolving a model through the router. #5290 (e37ee40addb2be759ee76ab1a5d208d671cab4d6) closes it for /v1/images/generations and /v1/images/edits, /v1/audio/transcriptions and its streaming form, /v1/live and /v1/realtime/calls with the standalone realtime sockets, and the non-account-qualified branch of /v1/alpha/search. Each has its own regression file, and a malformed scope drops the key rather than degrading to unrestricted.

    Deliberately not included, and not a gap in this issue: budgets and request or token rate limits, and any multi-tenant or external-store conversion. Those are separate asks.

    PRs here target dev, so GitHub does not auto-close.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions