Skip to content

feat(models): per-organization access to unlisted models - #1237

Merged
PierreLeGuen merged 7 commits into
mainfrom
feat/organization-model-access
Oct 21, 2026
Merged

PierreLeGuen merged 7 commits into
mainfrom
feat/organization-model-access

Conversation

@PierreLeGuen

@PierreLeGuen PierreLeGuen commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Part of nearai/infra#264. Base: main (it builds on #1233, which is merged).

Seven commits:

  1. feat(models): store which organizations may use a model: the table and its repository.
  2. feat(models): restrict unlisted models to organizations that hold a grant: the rules and every call site.
  3. feat(admin): manage model access grants and publish them for gateways: the admin routes and the internal document.
  4. test(e2e): cover per-organization model access.
  5. fix(models): close the review findings on model access: see "Review round" below.
  6. test(e2e): give the routes added on main a model access decision: see "Rebase onto main" below.
  7. fix(models): close the second review round on model access: see "Second review round" at the end.

What and why

#1233 added isListed. It keeps a model out of the public catalogs, but anyone who knows the model's name can still call it if it has a provider.

This PR makes an unlisted model a restricted model. An organization can use it through this API only while it holds a grant. Everyone else gets exactly the answer a model that does not exist gets. Grants are (organization, model) pairs, managed by admins.

Example: model X is unlisted and organization A holds a grant. A can call X. Nobody else can, and nobody else can tell that X exists.

Access rules

  • A listed model is open to everyone. Grants on it are kept but have no effect, so access can be prepared before a model is unlisted.
  • An unlisted model is usable only by organizations that hold a grant.
  • A grant counts only while both the organization and the model are active.
  • Allow-only. There are no deny rules for listed models, and no per-organization prices or limits.
  • Usage recording is not affected. POST /v1/internal/usage still records usage on any active model for any organization: usage that was served is always billed.
  • The public catalogs (GET /v1/models, GET /v1/model/list, GET /v1/model/{name}) are not affected. They stay public-only.

How it is enforced

Every lookup of a model for a request now states who is asking:

pub enum ModelCaller {
    Anonymous,          // no organization known: listed models only
    Organization(Uuid), // listed models, plus unlisted models it holds a grant for
    Trusted,            // the platform itself: every model
}

ModelsServiceTrait::resolve_and_get_model, get_model_by_name and resolve_alias_cached, CompletionServiceTrait::get_model and AttestationServiceTrait::get_attestation_report take the caller. A call site that does not say who is asking does not compile.

All of them end in one function, services::models::resolve_model_cached. It returns None both for an unknown name and for a model the caller may not use. The call sites cannot tell the two apart, so each route answers with the unknown-model response it already had. No new error variant, status, type or message.

Who asks what:

Call site Caller
Inference routes with an API key: chat completions, completions, responses (legacy and native), Anthropic messages and count_tokens, embeddings, image generations and edits, audio transcriptions, rerank, score, privacy classify and redact, System One the key's organization
Completion service (create_chat_completion, create_chat_completion_stream) the request's organization
GET /v1/attestation/report the key's organization
Deprecation response headers the key's organization (anonymous if there is none)
GET /v1/attestation/ita-token (public) anonymous
GET /v1/model/{name} (public) anonymous (unchanged, resolve_public_model)
Conversation titles (the title model) trusted, through PlatformModel (below)
Billing of the auto-redact classify pass trusted: a constant the platform chose, already run
Usage recording, admin flows, startup jobs not changed: they use their own repositories and apply no access rule

Paths that needed more than a new argument:

  1. Attestation report cache. Reports without a nonce are cached by model name, and a cache hit skips the build where the model used to be resolved. The model is now resolved for the caller before the cache lookup. Otherwise a report cached for an organization with a grant would have been served to anyone.
  2. Chat completions pre-dispatch checks. The route reads the catalog list before the completion service resolves the model, and can answer from it ("model X does not support video input"). The row is now read only if the organization may use the model.
  3. Strict alias mode (x-no-aliasing) and the alias warning name the canonical model behind an alias. Both now resolve for the caller.
  4. Responses API image dispatch. The service looks the model up and then calls the provider pool directly for image models. That lookup now takes the organization.
  5. Deprecation headers. The middleware adds x-model-deprecation-date and x-model-successor to successful responses. A streaming POST /v1/responses answers 200 before its model is resolved and fails inside the stream, so "the response is a success" does not mean the caller may use the model. The middleware now resolves the model for the request's own caller.
  6. Models the platform chooses. The title of a conversation is written by a model the platform picks (TITLE_GENERATION_MODEL), billed to the organization. It must not depend on that organization's grants. CompletionServiceTrait::create_platform_chat_completion runs a PlatformModel and resolves it as trusted. A PlatformModel cannot be built from a string: its constructors are a closed list that reads platform configuration, and it replaces whatever model the request names. A model name from a request cannot take that path.

GET /v1/attestation/report now runs under the API-key middleware variant that also hands the handler the key's organization. It is the same credential check (the two middlewares share one function).

Cost

  • Listed model: nothing new on the hot path. No new database query and no new lock. The check is model.is_listed on the row that was already resolved.
  • Unlisted model: one grant lookup per (organization, model), cached next to the existing resolve cache with the same 30 second TTL. Both answers are cached, so an organization that keeps asking for a model it may not use does not reach the database every time.
  • Grant and revoke drop one cached answer, the one for that organization and that model. They do not clear the model list cache or the resolved models: a grant changes neither, and reloading the list asks every backend for its metadata. A grant or revoke that changes nothing invalidates nothing.
  • invalidate_models_cache() (catalog writes) clears the grant cache with the others.

Admin API

Same authentication, token permissions and error shapes as the other admin model routes. Model names with a slash must be URL-encoded. Unknown model or organization: 404 (model_not_found, organization_not_found).

Route Token permission Answer
GET /v1/admin/models/{model_name}/access read the model (id, modelName, isListed, isActive) and the organizations that hold a grant
PUT /v1/admin/models/{model_name}/access/{org_id} write 200 with the grant. Idempotent: a second PUT returns the same grant
DELETE /v1/admin/models/{model_name}/access/{org_id} write 204. Idempotent
GET /v1/admin/organizations/{org_id}/model-access read the models the organization holds grants for
// GET /v1/admin/models/vendor%2Fmodel/access
{
  "id": "0b0e…",
  "modelName": "vendor/model",
  "isListed": false,
  "isActive": true,
  "organizations": [
    {
      "organizationId": "5f1c…",
      "organizationName": "…",
      "grantedAt": "2026-10-12T15:00:00Z",
      "grantedByUserId": "1111…",
      "grantedByUserEmail": "…"
    }
  ]
}

A model is looked up by canonical name, active or not, so a grant can be reviewed or revoked while its model is inactive. The organization must be active.

Internal API for gateways

GET /v1/internal/model-access, behind the service token of POST /v1/internal/usage (CLOUD_API_USAGE_TOKEN), answered with Cache-Control: no-store. 503 until the token is configured.

{
  "object": "model_access",
  "models": [
    { "id": "vendor/model", "organization_ids": ["5f1c…", "9a2e…"] },
    { "id": "vendor/other-model", "organization_ids": [] }
  ]
}

It lists every unlisted model, active or not, with the ids of the active organizations that hold a grant (an empty list when there are none). A model that is not in the document is unrestricted. That is why a model that was deactivated here stays in it: a lane may still be serving it. A gateway polls the document and enforces access per request. It is read from the database on every call, so a change shows on the next poll of any instance.

Storage

One migration file, two tables. It takes the next free number at merge time; it is V0097__add_organization_model_access.sql on the branch (main's highest is V0096).

CREATE TABLE organization_model_access (
    organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    PRIMARY KEY (organization_id, model_id)
);
CREATE INDEX idx_organization_model_access_model_id ON organization_model_access (model_id);

CREATE TABLE organization_model_access_log (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    organization_id UUID NOT NULL REFERENCES organizations(id),
    model_id UUID NOT NULL REFERENCES models(id),
    action VARCHAR(10) NOT NULL CHECK (action IN ('grant', 'revoke')),
    acting_user_id UUID REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Why a migration and not an existing column: a grant is a relationship between two rows, written by admins. organizations.settings is customer-editable, so it must not hold access state.

Audit. A revocation deletes its grant row, so the grants table cannot say who had access when. The log is append-only: one row per grant and one per revocation, with the organization, the model, the acting admin and the time. The row is written in the same statement as the change, and only when the change happened: granting a grant that exists, or revoking one that does not, writes nothing. Nothing in the API reads it. Its one text column (action) is registered as approved plaintext in the database-encryption field registry.

Deleting an organization removes its grants in the same transaction, like it revokes its reporting tokens, and logs each one as a revocation with no acting user.

User erasure needs no change. Both tables hold ids only, and the admin's email is read from users when grants are listed. The log's foreign keys carry no ON DELETE action, like the role audit log (V0077): erasure rewrites the users row in place and keeps its id.

Rollout

  • A model that is already unlisted is restricted the moment this version starts, and the grants table starts empty. Nobody can call such a model through this API until a grant exists.
  • Checked on 2026-10-21: this cuts nobody off today. Prod has one unlisted model, and the API sent it no provider request in the last 30 days. Its traffic goes through a gateway lane, whose usage is recorded through POST /v1/internal/usage and stays open. That lane's organization needs its grant before the gateway turns its own enforcement on.
  • Order for a model that has a provider: grant, then unlist. Grants can be created for a listed model in advance. They have no effect until the model is unlisted, and they apply from that moment.
  • A gateway must switch its own enforcement on only after the grants exist. Until then the document lists the model with an empty organization list, which a gateway that enforces it reads as "nobody".
  • Rolling deploy. An instance that still runs the old version does not check grants: it serves an unlisted model to any caller that knows its name, as today. The restriction holds once every instance runs this version.
  • A rollback reopens restricted models to any caller that knows their names. The two tables can stay.
  • Delay on other instances. A grant or a revocation applies at once on the instance that served the admin request, and within 30 seconds (the resolve cache TTL) on the others. After isListed: false, enforcement also follows on the other instances within those 30 seconds. But the pre-dispatch metadata read from the 5 minute list cache (the alias name, the input modality answer) can linger there for a model that was public minutes earlier.
  • One known race. A grant lookup that is in flight while a grant is revoked can put the old answer back in the cache after the invalidation. It is bounded by the same 30 seconds. The resolve cache has the same documented limit.
  • Migration number. The runner refuses to start when it finds an unapplied migration older than the newest applied one. So the file takes the next free number when it merges, and no lower number may reach a database after it.
  • Deprecation. Grants belong to a model. If an unlisted model is deprecated in favour of another unlisted model, the successor needs its own grants.

Behaviour changes for existing clients

  • isListed: false through the existing PATCH /v1/admin/models now cuts off callers without a grant. Before, it only hid the model from the catalogs.
  • The attestation report reports an unknown model before an invalid nonce. It used to be the other way round. Both are still a 400. (The model is now resolved before the report cache.)
  • The public ITA token route never serves an unlisted model, even when the caller's organization holds a grant: the route has no caller.
  • A successor is named only to a caller that may use it. x-model-successor is left out when the successor does not resolve for the caller, and the public catalogs keep a successor only if it is a listed model. A successor that is no longer active is left out the same way. The trusted GET /v1/internal/models keeps it.
  • The attestation service resolves the model through the shared 30 second resolve cache, like the completion service, instead of reading the database on every report build.

Known limit

An unknown name and a refused name do different database work: the unknown name misses on every request, the refused one is answered from caches. A caller close enough to the database to measure that could tell the two apart by timing. The responses themselves are equal.

Tests

Unit:

  • services::models: the public views name only a listed successor; the caller rules, the grant cache (both answers, TTL, full invalidation), access-only invalidation (one decision dropped, the list and the resolved model not read again), a failed grant lookup is an error and never an allow, the trait defaults fail closed.
  • Admin service: a grant or a revocation drops exactly that one access decision and never the model caches; one that changes nothing invalidates nothing; a grant for an organization deleted meanwhile is organization_not_found.
  • Completion service: a refused model gets the error of an unknown one (blocking and streaming); a granted organization is served; a platform model is served without a grant, and replaces the model the request names.
  • Attestation: a report cached for a granted organization is not served to anyone else; the public ITA flow treats an unlisted model as unknown.
  • Admin token policy: the two listings are reads, grant and revoke are writes.

E2E (organization_model_access.rs):

  • A table over every inference route that takes a model name (20 cases), on models that have a provider, a planned deprecation and a successor. An organization without a grant gets a response equal to the unknown-model response: status, headers and body. With a grant the request is served and carries the deprecation headers. The same through the model's alias.
  • Deprecation headers: none without a grant on any route of the table, the streaming Responses route included; present with a grant; gone after a revocation.
  • A listed model with an unlisted successor: the header names the successor only to an organization that holds a grant for it, and the public catalogs leave it out until it is listed.
  • A guard over every router builder in src/lib.rs, and over the attestation, credit transfer and organization credential routers it merges: each registered method and path is either in the table or listed with the reason it is not. A new route fails the test until that decision is made.
  • Pre-dispatch checks (video modality, strict alias mode, attestation alias header) do not disclose an unlisted model.
  • Grant, then revoke: both apply at once, also over a cached decision.
  • Listing opens a model, unlisting restricts it, grants prepared while listed apply from then on.
  • A grant on an inactive model has no effect here, and the model stays in the gateway document.
  • The audit log: one row per grant and per revocation, none for a call that changes nothing, a revocation with no acting user when the organization is deleted.
  • Admin routes: happy paths, idempotency, 404s, token permissions.
  • GET /v1/internal/model-access: content and authentication.
  • The public ITA token route treats an unlisted model as unknown.
  • Usage is recorded for an organization without a grant.

Run locally on the last commit, against a fresh PostgreSQL database each:

Command Result
cargo fmt --all -- --check clean
cargo clippy --all-targets --all-features -- -D warnings clean
cargo nextest run --lib --bins 2654 passed, 8 skipped
cargo nextest run --test e2e_all 1072 passed, 11 skipped
cargo nextest run --test integration_tests 8 passed, 1 skipped
cargo test --locked -p database --test hot_path_typed_queries --test billing_typed_queries --test credit_allocation --test session_rotation --test staking_wallet_binding -- --test-threads=4 46 passed
cargo test --locked -p database --test credit_transfers -- --test-threads=1 20 passed
cargo check --all-targets on each commit clean

The existing suite is unchanged apart from test doubles that implement the changed traits. No existing assertion was weakened.

I also broke three checks on purpose to see the new tests fail: the embeddings handler asking as a trusted caller, the chat pre-dispatch gate disabled, and the deprecation middleware resolving as trusted again. Each time the matching tests failed.

Review round

The fifth commit closes the findings of an independent review:

  • The deprecation headers leaked an unlisted model on the streaming Responses route (point 5 above).
  • Grant and revoke cleared every model cache; they now drop one access decision.
  • The title model depended on the caller's grants (point 6 above).
  • The gateway document left out inactive unlisted models.
  • Revocations left no record; the audit log was added.
  • The route guard only looked at two router builders and at the path.

Rebase onto main

Rebased on 2026-10-21 onto main at 71bbb0b (24 commits). The merge commit of 10-12 is gone.

Second review round

The seventh commit closes the review threads of 10-12:

  • A grant could outlive its organization. A grant raced with the organization's deletion could land after the deletion had removed the other grants. The grant now holds the organization row (FOR SHARE) and inserts only while the organization is active, in one transaction; otherwise it answers 404 organization_not_found.
  • An unlisted successor was visible. A listed model could name an unlisted successor in x-model-successor and in the public catalogs. See "Behaviour changes" above.
  • A failed access check during alias resolution is logged. The alias still stays unresolved.
  • Docs: the four admin access routes document 403; revoke states that the organization must be active; the gateway document says its entries are canonical names.
  • No backfill: see the rollout note checked on 2026-10-21.

@claude

claude Bot commented Oct 12, 2026

Copy link
Copy Markdown

Review: per-organization access to unlisted models

No critical issues found. I checked these areas:

  • Enforcement choke point: every request-path resolution goes through resolve_model_cached / caller_can_use_model. A failed grant lookup returns an error, never an allow. Listed models return early, so the hot path costs nothing new.
  • Trusted call sites: there are only two in production. One is PlatformModel (title generation), whose name is never taken from the request. The other is billing for the auto-redact classify pass, where the model is the constant DEFAULT_PII_MODEL. Neither lets a model name from a request through.
  • Attestation cache: resolving the model for the caller before the no-nonce cache lookup closes the cross-org cache-hit leak.
  • Deprecation middleware: it resolves for the request's own caller, so the streaming Responses route (200 before resolve) no longer leaks headers.
  • SQL: all queries are parameterized. Grant/revoke and their audit rows are written in a single statement via a CTE, so they cannot get out of step. The grant lookup is served by the PK; the reverse direction has its own index. I found no N+1 queries.
  • Migration / rolling deploy: the migration only adds tables, so old instances are unaffected. The audit log FKs have no ON DELETE, which is safe because I found no hard deletes of organizations or models outside tests.

Non-blocking notes:

  • GET /v1/internal/model-access lists models by canonical model_name only, and the contract is "a model that is not in the document is unrestricted". A gateway that receives a request by alias must map it to the canonical name (e.g. via /internal/models aliases) before checking. Otherwise the alias falls into the unrestricted case. Consider adding the aliases to each entry, or documenting this requirement for gateway owners.
  • The rollout notes already cover the in-flight-load race on the access cache (bounded by the 30s TTL) and the fact that already-unlisted models are locked the moment this version starts. Make sure ops seeds grants before deploying if any unlisted model currently has live callers.

✅

@ironloopai

ironloopai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Review · Status

🟩 Completed

IronLoop completed the review and posted it to GitHub.

Result

Open submitted review →

Run details
  • Run: d54c39c1-957d-40e4-aa07-2a8893455da6
  • Base: main at 34fba04
  • Head: feat/organization-model-access at 5a858e0
  • Created: 2026-10-12 17:30 UTC
  • Updated: 2026-10-12 17:37 UTC

Automatic trigger · attempt 1 of 3 · completed in 6m 40s

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a858e008f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/database/src/repositories/admin_composite.rs
Comment thread crates/api/src/middleware/model_deprecation.rs Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T17:34:57.129389Z 5a858e0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review · Summary

🟢 No actionable findings

No actionable defects found in the reviewed pull-request change.

Validation
  • ✅ Access-control paths — Static tracing found caller-aware model resolution and fail-closed grant checks across the changed inference, response, attestation, and administrative access paths.
  • ✅ Change coverage — The changed grant lifecycle, cache invalidation, internal access document, and route-level coverage were inspected.
Review details
  • Run: d54c39c1-957d-40e4-aa07-2a8893455da6
  • Attempts: 1

@lloydmak99 lloydmak99 left a comment

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.

Per-organization access to unlisted models looks sound. Every request-path model lookup funnels through resolve_model_cached → caller_can_use_model (services/models/mod.rs): listed models short-circuit to allowed, unlisted models allow only Trusted or a granted Organization, failed grant lookups error rather than allow, and refused models return the same NotFound as an unknown name. All inference call sites pass Organization(api_key.org); public routes pass Anonymous; platform/title/auto-redact paths use Trusted on constants. Grant/revoke write the grant and audit rows atomically, the hot-path check filters o.is_active AND m.is_active, org deletion cascades with audit logging, and migration V0094 is additive (safe rolling deploy).

No blocking issues found. Minor notes, none blocking:

  • The attestation report path resolves for the caller before the no-nonce cache lookup, and the deprecation middleware resolves per-request — worth a glance to confirm these close the cross-org cache/header leaks as intended.

Checks: git diff --check and cargo fmt --all -- --check passed; local cargo build/tests couldn't run (environment lacks the cc linker), but CI is fully green on head 5a858e0 — unit, integration, E2E, lint, cargo audit/deny all passing.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 OpenCodeReview found 5 issue(s) in this PR.

  • ✅ 5 posted as inline comment(s)
  • 📝 0 posted as summary

⚠️ 3 warning(s) occurred during review.

Comment thread crates/api/src/routes/admin_model_access.rs
Comment thread crates/api/src/routes/admin_model_access.rs
Comment thread crates/services/src/admin/ports.rs Outdated
Comment thread crates/services/src/models/mod.rs Outdated
Storage for per-organization access to unlisted models. Nothing reads it
for a request yet: the next commit enforces it.

- organization_model_access (organization_id, model_id, created_at,
  created_by_user_id), primary key (organization_id, model_id), index on
  model_id (V0097). A grant is a relationship between two rows, written
  by admins, so no existing column can carry it.
- ModelsRepository::organization_has_model_access: whether a grant is in
  effect. A grant counts only while the organization and the model are
  both active. The trait default refuses, so an implementor that keeps
  no grants cannot open a model.
- ModelsRepository::list_restricted_model_access: every active unlisted
  model with the active organizations that hold a grant. The trait
  default fails instead of returning an empty list, which a reader would
  take to mean that no model is restricted.
- AdminRepository: look a model up for access management, grant, revoke,
  list by model and by organization.
- Deleting an organization removes its grants in the same transaction,
  as it already revokes its reporting tokens.

Part of nearai/infra#264.
…rant

Until now `isListed: false` only kept a model out of the public
catalogs: anyone who knew its name could call it. An unlisted model is
now a restricted model. An organization may use it only while it holds
a grant, and everyone else gets exactly the answer a model that does not
exist gets.

Every lookup of a model for a request states who is asking:

- ModelCaller::Anonymous: listed models only.
- ModelCaller::Organization(id): listed models, plus the unlisted models
  the organization holds a grant for.
- ModelCaller::Trusted: every model (the platform itself).

ModelsServiceTrait::resolve_and_get_model, get_model_by_name and
resolve_alias_cached, CompletionServiceTrait::get_model and
AttestationServiceTrait::get_attestation_report take the caller, so a
call site that does not say who is asking does not compile. They all
end in resolve_model_cached, which returns None both for an unknown name
and for a model the caller may not use. Each route therefore answers
with the unknown-model response it already had.

Callers: inference routes and the completion service use the API key's
organization, the attestation report too, the public ITA token route is
anonymous, the deprecation-header middleware and the billing of the
auto-redact classify pass are trusted. Usage recording and admin flows
are not changed.

Paths that needed more than the new argument:

- Attestation report: nonce-less reports are cached by model name and a
  cache hit skips the build where the model was resolved. The model is
  now resolved for the caller before the cache lookup, so a report cached
  for a granted organization is not served to anyone else. The service
  shares the resolve cache of ModelsServiceImpl for this.
- Chat completions reads the catalog list before the completion service
  resolves the model and can answer from it (input modalities). The row
  is read only if the organization may use the model.
- Strict alias mode and the alias warning name the canonical model
  behind an alias; both resolve for the caller.
- The Responses API looks the model up and calls the provider pool
  directly for image models; that lookup takes the organization.
- GET /v1/attestation/report runs under the API-key middleware variant
  that also hands the handler the key's organization. It is the same
  credential check.

Cost: a listed model pays nothing new (no database query, no lock): the
check is `is_listed` on the row already resolved. An unlisted model
costs one grant lookup per (organization, model), cached with the
resolve cache (same TTL, positive and negative answers) and cleared by
invalidate_models_cache().

Part of nearai/infra#264.
Admin routes, with the authentication, token permissions and error
shapes of the other admin model routes:

- GET /v1/admin/models/{model_name}/access: the model (id, modelName,
  isListed, isActive) and the organizations that hold a grant. Read.
- PUT /v1/admin/models/{model_name}/access/{org_id}: grant. Idempotent:
  a second PUT returns the existing grant unchanged. Write.
- DELETE /v1/admin/models/{model_name}/access/{org_id}: revoke.
  Idempotent. Write.
- GET /v1/admin/organizations/{org_id}/model-access: the models an
  organization holds grants for. Read.

Unknown model or organization: 404. A model is looked up by canonical
name, active or not, so a grant can be reviewed or revoked while its
model is inactive; the organization must be active. The grant keeps the
admin who made it; a revocation deletes the row and is logged with the
admin's id.

Granting or revoking clears the cached access decisions, so it applies
to the next request on the instance that served it. Other instances
follow within the resolve cache TTL (30 seconds).

Internal route for gateways, behind the service token of
POST /v1/internal/usage and answered with Cache-Control: no-store:

  GET /v1/internal/model-access
  {"object":"model_access",
   "models":[{"id":"<model name>","organization_ids":["<uuid>", ...]}]}

It lists every active unlisted model with the active organizations that
hold a grant (an empty list when there are none). A model that is not
in the document is unrestricted. It is read from the database on every
call.

Part of nearai/infra#264.
- A table over every inference route that takes a model name. For an
  unlisted model that has a provider, an organization without a grant
  gets a response equal to the unknown-model response of that route:
  status, headers and body. With a grant the request is served.
- A guard that fails when a route is added to the inference routers
  without being added to that table, or to the list of routes that take
  no model name.
- The pre-dispatch checks (input modality, strict alias mode, alias
  header on the attestation report) do not disclose an unlisted model.
- Grant, then revoke: both apply at once on the same instance, also over
  a cached decision.
- Listing a model opens it, unlisting restricts it, and grants prepared
  while it was listed apply from then on. A grant on an inactive model
  has no effect.
- Admin routes: happy paths, idempotency, 404s, token permissions.
- GET /v1/internal/model-access: content and authentication.
- The public ITA token route treats an unlisted model as unknown.
- Usage is recorded for an organization that holds no grant.

Part of nearai/infra#264.
- Deprecation headers leaked an unlisted model. A streaming
  POST /v1/responses answers 200 before its model is resolved, and the
  header middleware then looked the model up as a trusted caller, so a
  caller without a grant got x-model-deprecation-date and
  x-model-successor. The middleware now resolves for the request's own
  caller: the API key's organization, or anonymous when there is none.
- Grant and revoke cleared the model list cache and the resolved models,
  which reloads the list from every backend. They now drop the one
  cached access decision (ModelsServiceTrait::invalidate_model_access),
  and only when the call changed something.
- The title model depended on the caller's grants: the platform chooses
  it, but it was resolved for the request's organization. It now runs
  through CompletionServiceTrait::create_platform_chat_completion, which
  resolves a PlatformModel as trusted. A PlatformModel cannot be built
  from a string and replaces the model the request names, so a model
  name from a request cannot take that path.
- GET /v1/internal/model-access lists every unlisted model, active or
  not. A lane can still be serving a model that was deactivated here,
  and a model missing from the document is read as unrestricted.
- Grants and revocations are audited: organization_model_access_log,
  one row per change with the acting user, written in the statement that
  makes the change and only when it changed something. Deleting an
  organization logs its removed grants as revocations with no acting
  user. Same migration file (V0097, not applied anywhere yet). The log's
  action column is registered as approved plaintext for the database
  encryption checks.
- Tests: every fixture model of the access table has a planned
  deprecation, so the table compares response headers too, and a
  dedicated test covers the headers with and without a grant. The route
  guard reads every router builder by method and path. Aliases are
  covered on every text route.

Part of nearai/infra#264.
The route guard fails for any registered route without a decision on
record. Since the branch was cut, main added:

- GET /v1/attestation/pins (#1263): public, the attested providers'
  trust pins. It takes no model name and reads no catalog row.
- The credit transfer and organization credential routes (#1271). They
  take no model name. Their routers are built in their own modules, so
  the guard now scans those two files as well as src/lib.rs and the
  attestation router; otherwise a model route added there later would
  not be seen.

Part of nearai/infra#264.
@PierreLeGuen
PierreLeGuen force-pushed the feat/organization-model-access branch from 5a858e0 to e392cc3 Compare October 21, 2026 16:52
@PierreLeGuen
PierreLeGuen deployed to Cloud API test env October 21, 2026 16:52 — with GitHub Actions Active
- A grant could outlive its organization. The admin service checked
  that the organization was active and then inserted the grant; a
  deletion in between removed the other grants and left this one. The
  grant now runs in one transaction that holds the organization row
  (FOR SHARE) and inserts only while it is active. Deletion updates the
  same row and removes the grants in its own transaction, so either it
  waits and removes this grant too, or the grant waits and answers
  organization_not_found.
- A listed model could name an unlisted successor, and its name reached
  callers that may not use it: x-model-successor and the successor of
  the public catalogs. The deprecation middleware now names the
  successor only if it resolves for the request's own caller (both
  lookups share the time limit), and the public catalogs keep a
  successor only if it is a listed model. The trusted catalog keeps it.
- A failed access check during alias resolution is logged; the alias
  still stays unresolved.
- Docs: the four admin access routes document 403; revoke states that
  the organization must be active; the gateway document says that its
  entries are canonical names, so a gateway checks the model an alias
  stands for.

Part of nearai/infra#264.
@PierreLeGuen
PierreLeGuen deployed to Cloud API test env October 21, 2026 17:23 — with GitHub Actions Active
@PierreLeGuen

Copy link
Copy Markdown
Contributor Author

On the non-blocking notes from the reviews of 10-12:

  • Aliases and GET /v1/internal/model-access (review comment above). The gateway (Gateway mode: enforce cloud-api's per-organization model access inference-proxy#297) first resolves the requested model to the entry it serves, then checks that entry's canonical id, so an alias cannot bypass the check there. I added this to the endpoint's docs in 686cad7: entries are canonical names, and a gateway checks the model an alias stands for.
  • Grants before deploy. Prod has one unlisted model today, and no request for it goes through this API's inference path, so the deploy cuts nobody off. Details are in the migration thread.
  • Attestation report cache and deprecation headers (Lloyd). Both are covered by e2e tests: the access table compares the full response of a refused request with an unknown model's, on the report route with and without a nonce, and deprecation_headers_are_only_added_for_a_caller_that_may_use_the_model covers the headers, the streaming Responses route included. Unit tests check that a report cached for an organization with a grant is not served to anyone else.

@lloydmak99 lloydmak99 left a comment

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.

This PR turns isListed: false into a real access boundary: unlisted models are usable only by organizations with an explicit grant, and refusals are indistinguishable from "model not found." The access-control logic is sound and fail-closed, and all previously raised blocking items are addressed.

  • services/models/mod.rs: resolution funnels through a single choke point (resolve_model_cached → caller_can_use_model) — listed/Trusted allowed, Anonymous denied, Organization grant lookups fail-closed (a failed lookup returns Err, never an allow), and refusals return the same NotFound as unknown names.
  • Grant/revoke vs. org-deletion race is handled: grant_model_access takes FOR SHARE on the org row and org deletion removes grants inside the FOR UPDATE deletion transaction, so the two serialize; grant/revoke+audit are atomic single-statement CTEs.
  • Attestation report cache no longer leaks — the model is resolved for the caller before the no-nonce cache lookup, so a disallowed caller errors out before any cache hit.
  • Deprecation/successor disclosure only names a successor the caller may actually use, including the streaming-200 path.
  • Migration V0097 is additive (two tables + indexes), safe for rolling deploy. Note: flipping an existing model to isListed: false is a hard access cutover — an operational rollout matter the author verified against prod (one unlisted model, no direct API inference traffic), not a code defect.

Checks: git diff --check and cargo fmt --all -- --check both clean; local compilation unavailable here (no cc linker), relied on reported-green CI (unit, integration, lint, audit/deny) on head 5a858e0.

@PierreLeGuen
PierreLeGuen merged commit 69e3598 into main Oct 21, 2026
16 checks passed

This branch was successfully deployed

1 active deployment
Cloud API test env — 686cad7c Deployed Oct 21, 2026 by PierreLeGuen via E2E tests #3889
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