From 8605c9783d5ed2be010a5114fc3ff758896a6fcd Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Tue, 4 Aug 2026 04:49:28 -0700 Subject: [PATCH] feat(models): rfc8693_obo auth mode, per-alias exchange scopes, identity-keyed mint cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the dedicated `rfc8693_obo` model auth mode (#955): model definitions gain an `obo_scopes` column (migration 069), the mint threads the scopes to the token-exchange leg (RFC 8693), and every dynamic mode pins its grant leg — a mode is a dialect commitment, not a hint the deployment profile resolves. Exchange-capable IdPs refuse an audience whose scope was not requested; this closes the structurally unmintable model-OBO path on token-exchange deployments. The model mint-cache is identity-keyed on the owning definition's alias (`__model_obo__:` per user, `__model_app__:` under the shared app principal), matching the MCP discipline where rows key on the unique server name. The bearer's shape lives in the row's audience/scopes columns and the freshness gate compares it on every read, so a re-aimed alias refuses its old row and overwrites the same key in place. Admin lifecycle (rename, re-aim, scope change, delete) purges a definition's own rows through one shared helper — sound because one definition owns each key; a sibling's rows are untouchable by construction. Cooldown and backoff additionally key on the dispatch shape, so an operator's config repair is an instant clean slate. Cause records, cooldowns, locks and memoization are per-alias end to end, and the session heartbeat reads refusal causes under the same keys. Console: default-deny write gating for dynamic rows (value-diff over the full column ladder, admin.mcp escalation, a never-blockable pure-disable carve-out), a two-tier validator (audience allow-list on every write; deployment-posture checks when the pair is chosen), one shared scopes parser whose omit-unchanged arm keeps over-cap DB-direct residue rows disarmable without ungating real changes, and served constraints (dynamic/scopes/app-identity mode lists, mode-to-profile pairing) so the shelf tracks the registry by data. The admin shelf gains the mode option, a scopes input with residue affordances, pairing-aware option greying, and a derived auth badge. Registry load refuses control characters in alias, audience, and scopes — including the C0 separator block that str.split() would silently collapse — and the C0/DEL class has one exported spelling shared by every surface. Profile-mismatch visibility warns at reload and boot with the mode-correct cause, gated on OIDC being enabled. Breaking: a stored `entra_obo` alias on a deployment whose `[oidc] obo_grant_profile` is `rfc8693` (or the inverse pairing) no longer mints via the profile-driven overload — the mint refuses before any IdP traffic with cause `grant_profile_mismatch`, and the `model.auth_fail_closed` policy governs static fallback. Such rows never minted usefully on scope-gating IdPs; the shelf now surfaces the pairing and the per-turn heartbeat names the refusal cause. Live-verified end to end: scoped token exchange mints, the warm cache serves with zero IdP calls, and the mode/profile mismatch refuses with zero IdP traffic (scripts/obo-e2e/keycloak_e2e.sh); the refresh-redemption profile's E1-E7 hold via scripts/obo-e2e/entra_e2e.py. Closes #955. --- docs/oidc.md | 53 +- docs/settings.md | 71 +- scripts/livepass.py | 9 +- scripts/obo-e2e/keycloak_e2e.py | 198 ++--- sdk/typescript/openapi-console.json | 58 +- tests/test_admin_model_registry_refresh.py | 681 +++++++++++++++- tests/test_app_js.py | 49 ++ tests/test_auth_identity.py | 6 + tests/test_model_provider_obo.py | 738 +++++++++++++++++- tests/test_model_registry.py | 237 ++++++ tests/test_server_node_models_metadata.py | 24 + turnstone/api/console_schemas.py | 36 +- turnstone/console/server.py | 294 ++++++- turnstone/console/static/admin.js | 194 ++++- turnstone/console/static/index.html | 16 + turnstone/core/auth.py | 9 + turnstone/core/mcp_client.py | 74 +- turnstone/core/mcp_oauth.py | 474 ++++++++--- turnstone/core/model_registry.py | 219 +++++- turnstone/core/oidc.py | 2 +- turnstone/core/session.py | 100 ++- turnstone/core/storage/_postgresql.py | 2 + turnstone/core/storage/_protocol.py | 1 + turnstone/core/storage/_schema.py | 8 +- turnstone/core/storage/_sqlite.py | 2 + turnstone/core/storage/_utils.py | 1 + .../versions/069_model_obo_scopes.py | 32 + turnstone/server.py | 18 +- 28 files changed, 3155 insertions(+), 451 deletions(-) create mode 100644 turnstone/core/storage/migrations/versions/069_model_obo_scopes.py diff --git a/docs/oidc.md b/docs/oidc.md index 4a12e179..f99aa409 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -137,28 +137,39 @@ always held to the strict public-address rule. ### Model gateway credentials The same OIDC registration can authenticate model gateways. A model definition -with `auth_mode = "entra_obo"` redeems the driving user's captured credential -for its exact `obo_audience`; `auth_mode = "entra_app"` uses the registration's -client ID and secret with Entra client credentials. Both bind the result through -the provider SDK's native credential option rather than injecting an override -header. The grant mode is never inferred: missing user context or a failed OBO -mint cannot switch an `entra_obo` definition to client credentials. +with `auth_mode = "entra_obo"` (Entra grant profile) or `auth_mode = +"rfc8693_obo"` (RFC 8693 token-exchange profile) redeems the driving user's +captured credential for its exact `obo_audience`; `auth_mode = "entra_app"` +uses the registration's client ID and secret with Entra client credentials. +All three bind the result through the provider SDK's native credential option +rather than injecting an override header. The grant mode is never inferred: +missing user context or a failed OBO mint cannot switch a delegated definition +to client credentials. -`entra_obo` needs the MCP encryption key, a credential captured for the driving -user, and delegated/admin-consented permission to the audience. It works under -either grant profile, with one RFC 8693 caveat: the model mint sends **no scope -parameter** (model definitions carry no per-row scopes, unlike MCP servers), so -the IdP must grant the alias's audience to the app client **by default**; on -Keycloak the exchange otherwise fails with "Requested audience not available" -(see issue #955 for the tracked fix). Turning `capture_user_credential` off -later stops *new* captures but does not invalidate credentials already stored, -so existing users keep minting. `entra_app` requires `obo_grant_profile = -"entra"` and a confidential-client secret; RFC 8693 client-credentials is not -implemented. Configure the permitted resource IDs in the runtime setting -`model.auth_audience_allowlist` before saving dynamic model definitions. -De-listing an audience later blocks every write that would arm or re-aim a -definition at it, but does not stop aliases already configured from minting — -disabling the row (the `admin.models` disarm lever) is what stops minting. See +Each dynamic mode pairs with the grant profile whose dialect it names: +`entra_obo` and `entra_app` require `obo_grant_profile = "entra"`; +`rfc8693_obo` requires `obo_grant_profile = "rfc8693"`. The pairing is +enforced when a write chooses a `(auth_mode, obo_audience)` pair — a same-pair +edit of a row saved before the pairing rule keeps working — and at runtime a +mismatched legacy row refuses to mint with `cause=grant_profile_mismatch` and +no IdP traffic. RFC 8693 client-credentials is not implemented. + +The delegated modes need the MCP encryption key, a credential captured for the +driving user, and delegated/admin-consented permission to the audience. +`rfc8693_obo` additionally carries `obo_scopes`, the space-separated scope +list its exchange leg requests: exchange-capable IdPs that gate audiences +behind optional scopes refuse the exchange without it ("Requested audience not +available"), which is why the scope-less Entra-named mode could never mint on +that profile (issue #955). Scopes are stored shape-checked only — whether a +value satisfies the IdP stays the IdP's call at mint time. Turning +`capture_user_credential` off later stops *new* captures but does not +invalidate credentials already stored, so existing users keep minting. +`entra_app` requires a confidential-client secret. Configure the permitted +resource IDs in the runtime setting `model.auth_audience_allowlist` before +saving dynamic model definitions. De-listing an audience later blocks every +write that would arm or re-aim a definition at it, but does not stop aliases +already configured from minting — disabling the row (the `admin.models` disarm +lever) is what stops minting. See [Settings](settings.md#model-backend-authentication) for permissions, failure policy, and lane identity rules. diff --git a/docs/settings.md b/docs/settings.md index aec3f955..b13318c1 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -56,31 +56,36 @@ from the new model's overrides or global defaults. ### Model backend authentication -Model definitions support three backend credential modes: +Model definitions support four backend credential modes: | `auth_mode` | Identity sent to the model gateway | |-------------|------------------------------------| | `static` | The definition's stored `api_key`. | | `entra_obo` | A caller-delegated Entra access token minted from that user's captured OIDC credential. | | `entra_app` | A shared app-identity token minted with Turnstone's OIDC client credentials. | +| `rfc8693_obo` | A caller-delegated access token minted from the captured credential via RFC 8693 token exchange, requesting the definition's `obo_scopes`. | -Dynamic modes require an exact `obo_audience` resource App ID URI. Before an +Dynamic modes require an exact `obo_audience` resource identifier. Before an admin can save one, an operator must add that literal audience to `model.auth_audience_allowlist` (comma- or newline-separated). Wildcards and base-URL host matching are intentionally unsupported, and a row whose effective mode is `static` refuses to store a new non-empty `obo_audience` on either create or update — an audience cannot be staged for a later flip -(clearing a stale value, or re-saving it unchanged, stays allowed). On a row -that is (or becomes) dynamic, every change except the tuning fields — context -window, temperature, max tokens, reasoning effort, and the two -reasoning-persistence toggles — also requires `admin.mcp`; service tokens do -not bypass this capability-escalation gate. The one exception is -de-escalation: a save whose only gated change is switching `enabled` off is a -pure disable, needs only `admin.models`, and skips validation — a de-listed -audience must never block disarming its own row. The gate is deny-by-default: -a field counts as auth-relevant unless it is provably neutral, so re-enabling -a disabled dynamic row, re-pointing its `base_url`, or swapping its provider -or alias all escalate. +(clearing a stale value, or re-saving it unchanged, stays allowed). +`obo_scopes` follows the same staging rule with the mode set inverted: only +`rfc8693_obo` reads it, so every other effective mode refuses to store a new +non-empty value, while clearing or re-saving one unchanged stays open. The +value itself is optional and shape-checked only — whether it satisfies the +IdP is decided at mint time. On a row that is (or becomes) dynamic, every +change except the tuning fields — context window, temperature, max tokens, +reasoning effort, and the two reasoning-persistence toggles — also requires +`admin.mcp`; service tokens do not bypass this capability-escalation gate. +The one exception is de-escalation: a save whose only gated change is +switching `enabled` off is a pure disable, needs only `admin.models`, and +skips validation — a de-listed audience must never block disarming its own +row. The gate is deny-by-default: a field counts as auth-relevant unless it +is provably neutral, so re-enabling a disabled dynamic row, re-pointing its +`base_url`, or swapping its provider or alias all escalate. Validation runs in two tiers, matching the MCP `oauth_obo` write rules. Row validity — the audience is allow-listed — applies to every gated write that @@ -100,33 +105,39 @@ require discovery to have completed — a config saved during an outage starts minting only once any authenticated request heals discovery. Until then calls warn and follow the fail-open/fail-closed policy above. -`entra_app` is supported only with `[oidc] obo_grant_profile = "entra"`. +Every dynamic mode pairs with exactly one grant profile: `entra_obo` and +`entra_app` require `[oidc] obo_grant_profile = "entra"`, and `rfc8693_obo` +requires `"rfc8693"`. The pairing is enforced at the posture tier, so a row +saved before the rule existed keeps accepting same-pair edits; its mints +refuse at runtime with `cause=grant_profile_mismatch` and no IdP traffic. Judge, output-guard, perception, utility, and sub-agent lanes inherit the -session's effective user for `entra_obo`. The perception memo is partitioned by -that principal as well as alias and content hash, so a result authorized as one -user cannot be served to another. Scheduled and wake-driven work retains the -workstream owner even when no user is connected. Eval and optimizer lanes are -registry-less development tools and therefore do not use dynamic model -authentication. +session's effective user for the delegated modes. The perception memo is +partitioned by that principal as well as alias and content hash, so a result +authorized as one user cannot be served to another. Scheduled and wake-driven +work retains the workstream owner even when no user is connected. Eval and +optimizer lanes are registry-less development tools and therefore do not use +dynamic model authentication. `entra_app` is an explicit model-definition choice; Turnstone never changes a -failed or ownerless `entra_obo` call into a client-credentials grant. An -`entra_obo` call with no effective user always refuses. A dynamic alias without -a real static key also always refuses instead of issuing its SDK-construction -placeholder. When a real static key is explicitly configured, mint failures -may use it by default; set `model.auth_fail_closed = true` to prohibit even that -fallback. A refusal is not routed through the model fallback chain. +failed or ownerless delegated call into a client-credentials grant. A +delegated-mode call with no effective user always refuses. A dynamic alias +without a real static key also always refuses instead of issuing its +SDK-construction placeholder. When a real static key is explicitly configured, +mint failures may use it by default; set `model.auth_fail_closed = true` to +prohibit even that fallback. A refusal is not routed through the model +fallback chain. Dynamic token caches are encrypted in `mcp_user_tokens`, shared across nodes, and memoized on each host. Unlinking a user's OIDC identity purges their -`entra_obo` rows and memo entries. `entra_app` rows belong to the shared +delegated-mode rows and memo entries. `entra_app` rows belong to the shared `__app__` identity and are not user-deprovisioned; after client-credential revocation, an already-minted app bearer remains usable until its recorded expiry. -`obo_audience` is literal and capped at 2048 characters. Environment-variable -expansion is deliberately not applied, so the allow-list decision cannot vary -by node or expand beyond the persisted boundary. +`obo_audience` and `obo_scopes` are literal and capped at 2048 characters +each. Environment-variable expansion is deliberately not applied, so the +allow-list decision cannot vary by node or expand beyond the persisted +boundary. ### Responses output controls (per-model) diff --git a/scripts/livepass.py b/scripts/livepass.py index 9eff7ec3..1e683dff 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -452,7 +452,7 @@ CONSOLE_TEMPLATE = """ enabled: true, temperature: null, max_tokens: null, reasoning_effort: null, surface_persisted_reasoning: true, replay_reasoning_to_model: false, - auth_mode: "static", obo_audience: "", + auth_mode: "static", obo_audience: "", obo_scopes: "", }; window.__putCount = 0; // Held under a private name too: auth.js's legacy window bridge @@ -499,7 +499,12 @@ CONSOLE_TEMPLATE = """ return reply({ auth_audience_allowlist: ["api://example-gateway"], auth_grant_profile: "entra", - dynamic_auth_modes: ["entra_app", "entra_obo"], + dynamic_auth_modes: ["entra_app", "entra_obo", "rfc8693_obo"], + scopes_auth_modes: ["rfc8693_obo"], + auth_mode_profiles: { + entra_app: "entra", entra_obo: "entra", + rfc8693_obo: "rfc8693", + }, }); if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL); if (url.indexOf("/model-definitions") >= 0) diff --git a/scripts/obo-e2e/keycloak_e2e.py b/scripts/obo-e2e/keycloak_e2e.py index fa7fd79f..cc40aac8 100644 --- a/scripts/obo-e2e/keycloak_e2e.py +++ b/scripts/obo-e2e/keycloak_e2e.py @@ -17,25 +17,18 @@ Checks E1–E7 mirror the Entra harness: E6 unconsented audience C → NOT token, credential SURVIVES E7 cache flush → re-mint -M1/M2 drive the MODEL-backend mint (``mint_obo_access_token``, issue #898) on -the same captured credential — the path an ``auth_mode=entra_obo`` model alias -takes, distinct from the classified MCP path above: - M1 model mint audience A → token carries A. Currently KNOWN-GAP on KC 26 - standard token exchange (issue #955: model definitions carry no per-row - scopes, so the exchange leg sends none and KC refuses the audience) — - accepted ONLY on the exact gap signature: kc_calls == 2 AND E1 - VERIFIED AND the exchange-leg refusal the mint swallows (captured via - a module-logger hook) carries the IdP's documented no-scope refusal - text. A None with any other signature — including a 2-call refusal - with different IdP error text (malformed exchange request) — is a - mint-path regression and FAILS the run - M2 warm re-mint serves the synthetic ``__model_obo__`` cache row with zero - IdP calls (KNOWN-GAP while blocked behind an M1 KNOWN-GAP, FAILED - behind an M1 failure) - -A KNOWN-GAP status counts as a passing run (exit 0): it marks a documented -frontier, scoped to its exact signature so a regression cannot hide under it; -the #955 fix flips those legs back to hard VERIFIED/FAILED checks. +M1-M3 drive the MODEL-backend mint (``mint_obo_access_token``, #898/#955) on +the same captured credential — the path an ``auth_mode=rfc8693_obo`` model +alias takes, distinct from the classified MCP path above: + M1 model mint audience A with the alias's exchange scopes → token carries A + (the #955 fix: model definitions now carry per-row ``obo_scopes``, so + the exchange leg requests the audience's scope exactly as MCP rows do) + M2 warm re-mint serves the synthetic ``__model_obo__`` cache row — + identity-keyed on the owning alias, audience + scopes in the row's + own columns — with zero IdP calls + M3 an entra-leg mode (``entra_obo``) on this rfc8693 deployment refuses + BEFORE any IdP traffic, recording cause=grant_profile_mismatch — the + mode/profile pairing that replaced the pre-#955 overload Env (set by keycloak_e2e.sh): KC_TOKEN_ENDPOINT, KC_ISSUER, KC_CLIENT_ID, KC_CLIENT_SECRET, @@ -55,16 +48,17 @@ from typing import Any import httpx -from turnstone.core import mcp_oauth as mcp_oauth_module from turnstone.core.mcp_crypto import ( MCPTokenCipher, MCPTokenCipherConfig, MCPTokenStore, ) from turnstone.core.mcp_oauth import ( - MODEL_OBO_CACHE_PREFIX, get_obo_access_token_classified, mint_obo_access_token, + model_mint_refusal_cause, + model_obo_cache_server, + model_obo_cause_key, ) from turnstone.core.oidc import OIDCConfig from turnstone.core.storage._sqlite import SQLiteBackend @@ -106,39 +100,6 @@ class _CountingClient: return await self._inner.post(*args, **kwargs) -# The IdP text of the #955 refusal: KC 26 standard token exchange rejecting -# an audience requested with no scope. Live-verified on the MCP leg (the -# comment beside the exchange builder in core/mcp_oauth.py records it); the -# model leg builds the identical exchange request minus the scope param, so -# the same error_description is expected — a live run must confirm the model -# leg's captured text matches before this narrowing is called proven. -_KNOWN_GAP_REFUSAL_TEXT = "requested audience not available" - - -class _MintFailureLogHook: - """Capture the exchange-leg refusal text ``mint_obo_access_token`` swallows. - - The mint catches ``MCPOAuthRefreshFailed`` and returns ``None``, so the - None the harness sees carries no cause. Wrapping the module logger - recovers it without touching the production mint: the log call happens - INSIDE the except block, so ``sys.exc_info()`` still holds the live - exception there. - """ - - def __init__(self, inner: Any) -> None: - self.inner = inner - self.mint_failures: list[str] = [] - - def warning(self, event: Any, *args: Any, **kwargs: Any) -> Any: - if event == "model_obo.mint_failed": - exc = sys.exc_info()[1] - self.mint_failures.append(str(exc) if exc is not None else "") - return self.inner.warning(event, *args, **kwargs) - - def __getattr__(self, name: str) -> Any: - return getattr(self.inner, name) - - def _password_login(cfg: dict[str, str]) -> str: """Headless direct-access grant → a real refresh token for the user.""" resp = httpx.post( @@ -219,12 +180,8 @@ async def _run(cfg: dict[str, str], refresh_token: str) -> None: ok, aud = aud_carries(r.token, cfg["AUD_A"]) row = storage.get_mcp_user_token(USER, "kc-a") cache_ok = row is not None and row["refresh_token_ct"] is None - # A local, so M1's KNOWN-GAP signature consumes it directly - # instead of re-scanning RESULTS message prefixes, which a - # relabel would silently flip. - e1_status = "VERIFIED" if ok and cache_ok else "FAILED" record( - e1_status, + "VERIFIED" if ok and cache_ok else "FAILED", f"E1 mint A (refresh→exchange): kind=token aud={aud} want={cfg['AUD_A']} " f"cache_row_refreshless={cache_ok}", ) @@ -298,71 +255,51 @@ async def _run(cfg: dict[str, str], refresh_token: str) -> None: f"E7 flush→re-mint: kind={r7.kind} kc_calls={client.posts - posts_before} (want >=1)", ) - # M1/M2 — MODEL backend mint (#898) on the rfc8693 profile: same - # captured credential and legs, but through mint_obo_access_token, - # the path an auth_mode=entra_obo alias takes. entra_obo is allowed - # under either grant profile (only entra_app is entra-only), and this - # is the one place that combination runs against a real IdP. + # M1-M3 — MODEL backend mint on the rfc8693 profile: same captured + # credential and legs as E1-E7, but through mint_obo_access_token — + # the path an auth_mode=rfc8693_obo alias takes, carrying the + # per-alias exchange scopes MCP rows always had (#955). The mint's + # cache and cause records are identity-keyed on the owning alias, so + # the harness names one per mode-variant exactly as a deployment + # would define separate rows. posts_before = client.posts - log_hook = _MintFailureLogHook(mcp_oauth_module.log) - mcp_oauth_module.log = log_hook # type: ignore[assignment] - try: - m1 = await mint_obo_access_token( - app_state=app_state, user_id=USER, audience=cfg["AUD_A"] - ) - finally: - mcp_oauth_module.log = log_hook.inner + m1 = await mint_obo_access_token( + app_state=app_state, + user_id=USER, + alias="model-a", + audience=cfg["AUD_A"], + scopes=cfg.get("SCOPE_A", ""), + grant_leg="rfc8693", + ) m1_kc_calls = client.posts - posts_before - m1_refusal = " | ".join(log_hook.mint_failures) - m1_refusal_matches = _KNOWN_GAP_REFUSAL_TEXT in m1_refusal.lower() - ok1, why1 = aud_carries(m1, cfg["AUD_A"]) if m1 else (False, "no token") - e1_verified = e1_status == "VERIFIED" if m1: - m1_status = "VERIFIED" if ok1 and m1_kc_calls > 0 else "FAILED" + ok1, why1 = aud_carries(m1, cfg["AUD_A"]) record( - m1_status, - f"M1 model mint (rfc8693): token={redact(m1)} aud_ok={ok1} ({why1}) " - f"kc_calls={m1_kc_calls} (want >=1)", - ) - elif m1_kc_calls == 2 and e1_verified and m1_refusal_matches: - # #955's exact signature, nothing broader: both mint legs ran - # against the live IdP (refresh grant + token exchange = 2 KC - # calls), E1 VERIFIED proves the shared legs are healthy, AND the - # swallowed exchange-leg error carries the IdP's documented - # no-scope refusal text. The TEXT check is what separates the - # documented gap from a mint-side exchange regression with the - # same call count (wrong audience parameter, dropped subject - # token, bad grant_type all also draw a 2-call refusal). The #955 - # fix flips this branch back to a hard VERIFIED/FAILED check. - m1_status = "KNOWN-GAP" - record( - m1_status, - "M1 model mint (rfc8693): no scope wire-through for model " - f"aliases — see issue #955 (kc_calls={m1_kc_calls}, refusal " - f"text matched {_KNOWN_GAP_REFUSAL_TEXT!r})", + "VERIFIED" if ok1 and m1_kc_calls > 0 else "FAILED", + f"M1 model mint (rfc8693_obo, scoped exchange): token={redact(m1)} " + f"aud_ok={ok1} ({why1}) kc_calls={m1_kc_calls} (want >=1)", ) else: - # None with any OTHER signature (no KC traffic, a single leg, - # unhealthy shared legs, or a 2-call refusal whose IdP error text - # is NOT the documented no-scope refusal) is a regression in or - # upstream of the mint, and must fail the run rather than wear - # the KNOWN-GAP label. - m1_status = "FAILED" record( - m1_status, - "M1 model mint (rfc8693): no token and the failure signature " - f"does not match the #955 gap (kc_calls={m1_kc_calls}, want 2 " - f"with E1 VERIFIED; e1_verified={e1_verified}; " - f"refusal_text_matched={m1_refusal_matches} " - f"captured={m1_refusal[:300]!r}) — mint-path regression, not " - "the no-scope exchange refusal", + "FAILED", + f"M1 model mint (rfc8693_obo): no token (kc_calls={m1_kc_calls}) — " + "the #955 scope wire-through should mint here", ) - # M2 — warm re-mint serves the synthetic __model_obo__ cache row with - # zero IdP calls, and the row is named so deprovisioning can find it. + # M2 — warm re-mint serves the synthetic __model_obo__ cache row — + # identity-keyed on the owning alias, audience + scopes in the row's + # own columns — with zero IdP calls, and the row is named so + # deprovisioning can find it by prefix. posts_before = client.posts - m2 = await mint_obo_access_token(app_state=app_state, user_id=USER, audience=cfg["AUD_A"]) - cache_row = storage.get_mcp_user_token(USER, f"{MODEL_OBO_CACHE_PREFIX}{cfg['AUD_A']}") + m2 = await mint_obo_access_token( + app_state=app_state, + user_id=USER, + alias="model-a", + audience=cfg["AUD_A"], + scopes=cfg.get("SCOPE_A", ""), + grant_leg="rfc8693", + ) + cache_row = storage.get_mcp_user_token(USER, model_obo_cache_server("model-a")) if m1: record( "VERIFIED" @@ -373,12 +310,31 @@ async def _run(cfg: dict[str, str], refresh_token: str) -> None: f"{'present' if cache_row is not None else 'MISSING'}", ) else: - # Blocked behind M1: inherit its classification, so a FAILED M1 - # cannot launder its downstream leg into a KNOWN-GAP pass. - if m1_status == "KNOWN-GAP": - record("KNOWN-GAP", "M2 model cache-hit: blocked behind M1 — see issue #955") - else: - record("FAILED", "M2 model cache-hit: blocked behind M1 — M1 failed, see above") + record("FAILED", "M2 model cache-hit: blocked behind M1 — M1 failed, see above") + + # M3 — the mode/profile pairing refusal that replaced the pre-#955 + # overload: an entra-leg mode on this rfc8693 deployment must yield + # None with ZERO IdP calls and record the grant_profile_mismatch + # cause the session heartbeat reads (under its own alias — a + # deployment defines the entra-mode variant as its own row). + posts_before = client.posts + m3 = await mint_obo_access_token( + app_state=app_state, + user_id=USER, + alias="model-a-entra", + audience=cfg["AUD_A"], + grant_leg="entra", + ) + m3_cause = model_mint_refusal_cause( + "model_obo", model_obo_cause_key("model-a-entra", grant_leg="entra"), USER + ) + record( + "VERIFIED" + if m3 is None and client.posts == posts_before and m3_cause == "grant_profile_mismatch" + else "FAILED", + f"M3 mode/profile mismatch refusal: token={redact(m3)} (want absent) " + f"kc_calls={client.posts - posts_before} (want 0) cause={m3_cause!r}", + ) finally: await inner.aclose() @@ -408,7 +364,7 @@ def main() -> int: print("\n=== summary ===") for status, msg in RESULTS: print(f" {status:>8} {msg}") - return 0 if all(s in ("VERIFIED", "SKIPPED", "KNOWN-GAP") for s, _ in RESULTS) else 1 + return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1 if __name__ == "__main__": diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index 1889a800..7e795d60 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -10932,6 +10932,11 @@ "title": "Obo Audience", "type": "string" }, + "obo_scopes": { + "default": "", + "title": "Obo Scopes", + "type": "string" + }, "source": { "default": "", "title": "Source", @@ -11062,6 +11067,11 @@ "title": "Obo Audience", "type": "string" }, + "obo_scopes": { + "default": "", + "title": "Obo Scopes", + "type": "string" + }, "source": { "default": "", "title": "Source", @@ -11192,6 +11202,11 @@ "default": "", "title": "Obo Audience", "type": "string" + }, + "obo_scopes": { + "default": "", + "title": "Obo Scopes", + "type": "string" } }, "required": [ @@ -11377,6 +11392,18 @@ ], "default": null, "title": "Obo Audience" + }, + "obo_scopes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Obo Scopes" } }, "title": "UpdateModelDefinitionRequest", @@ -11416,7 +11443,7 @@ "type": "array" }, "auth_grant_profile": { - "description": "Deployment [oidc] obo_grant_profile, or empty when single sign-on is not configured. entra_app requires 'entra'; entra_obo works under either profile. A transient discovery outage reports the configured profile, not empty.", + "description": "Deployment [oidc] obo_grant_profile, or empty when single sign-on is not configured. Each dynamic auth_mode pairs with exactly one profile (see auth_mode_profiles); the write validator refuses a new pairing that contradicts it. A transient discovery outage reports the configured profile, not empty.", "title": "Auth Grant Profile", "type": "string" }, @@ -11427,12 +11454,39 @@ }, "title": "Dynamic Auth Modes", "type": "array" + }, + "scopes_auth_modes": { + "description": "auth_mode values whose mint reads obo_scopes (the token-exchange scope request), same server-derived contract as dynamic_auth_modes; drives the scopes input's visibility.", + "items": { + "type": "string" + }, + "title": "Scopes Auth Modes", + "type": "array" + }, + "app_identity_auth_modes": { + "description": "auth_mode values that mint a shared app/deployment identity rather than a per-user one, same server-derived contract as dynamic_auth_modes; drives the model list's auth badge wording.", + "items": { + "type": "string" + }, + "title": "App Identity Auth Modes", + "type": "array" + }, + "auth_mode_profiles": { + "additionalProperties": { + "type": "string" + }, + "description": "Required [oidc] obo_grant_profile per dynamic auth_mode. Affordance for greying options that cannot validate under this deployment's profile; the write validator remains the authority.", + "title": "Auth Mode Profiles", + "type": "object" } }, "required": [ "auth_audience_allowlist", "auth_grant_profile", - "dynamic_auth_modes" + "dynamic_auth_modes", + "scopes_auth_modes", + "app_identity_auth_modes", + "auth_mode_profiles" ], "title": "ModelAuthConstraintsResponse", "type": "object" diff --git a/tests/test_admin_model_registry_refresh.py b/tests/test_admin_model_registry_refresh.py index 051d177f..31598fe5 100644 --- a/tests/test_admin_model_registry_refresh.py +++ b/tests/test_admin_model_registry_refresh.py @@ -38,7 +38,10 @@ from turnstone.console.server import ( admin_update_model_definition, ) from turnstone.core.model_registry import ( + APP_IDENTITY_MODEL_AUTH_MODES, DYNAMIC_MODEL_AUTH_MODES, + MODEL_AUTH_MODE_PROFILES, + SCOPES_MODEL_AUTH_MODES, ModelConfig, ModelRegistry, ) @@ -87,6 +90,7 @@ def _seed_model_def( enabled: bool = True, auth_mode: str = "static", obo_audience: str = "", + obo_scopes: str = "", capabilities: str = "{}", ) -> None: """Insert a model definition row directly via the storage API.""" @@ -103,6 +107,7 @@ def _seed_model_def( created_by="admin", auth_mode=auth_mode, obo_audience=obo_audience, + obo_scopes=obo_scopes, ) @@ -956,6 +961,9 @@ def test_auth_constraints_serves_allowlist_and_profile( # Server-derived, so the shelf's mode affordances track the registry's # classification by data (the client hand-list is only a fail-open fallback). assert body["dynamic_auth_modes"] == sorted(DYNAMIC_MODEL_AUTH_MODES) + assert body["scopes_auth_modes"] == sorted(SCOPES_MODEL_AUTH_MODES) + assert body["app_identity_auth_modes"] == sorted(APP_IDENTITY_MODEL_AUTH_MODES) + assert body["auth_mode_profiles"] == dict(MODEL_AUTH_MODE_PROFILES) def test_auth_constraints_empty_allowlist_is_present_not_absent( @@ -1546,12 +1554,72 @@ def test_entra_app_create_rejects_non_entra_profile( assert "RFC 8693" in resp.json()["error"] -def test_entra_obo_allowed_on_rfc8693_profile( +def test_entra_obo_create_rejects_rfc8693_profile( + storage: SQLiteBackend, +) -> None: + """Every dynamic mode pairs with the profile whose dialect it names, so + the Entra-named delegated mode refuses a token-exchange deployment — and + the refusal names the mode that DOES fit it. Revises the pre-#955 ruling + that permitted the overload (the combination could never mint). + """ + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + + resp = _dynamic_create(client, alias="gateway") + + assert resp.status_code == 400, resp.text + assert "obo_grant_profile" in resp.json()["error"] + assert "rfc8693_obo" in resp.json()["error"] + + +def test_rfc8693_obo_create_rejects_entra_profile( + storage: SQLiteBackend, +) -> None: + """The pairing discriminates in both directions.""" + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + + resp = _dynamic_create(client, alias="gateway", auth_mode="rfc8693_obo") + + assert resp.status_code == 400, resp.text + assert "obo_grant_profile" in resp.json()["error"] + assert "entra_obo" in resp.json()["error"] + + +def test_unmapped_dynamic_mode_is_refused_at_pair_choose( storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The delegated leg is a refresh-token grant and works under either - profile — the pair above proves the check discriminates per mode. + """Fail-closed IN code, not by map absence: a dynamic mode nobody paired + draws its own 400 naming the remedy when a write CHOOSES it — the + registry drift test is only the belt. + """ + from turnstone.console import server as server_module + + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + # DYNAMIC_MODEL_AUTH_MODES stays intact — only the pairing map empties. + monkeypatch.setattr(server_module, "MODEL_AUTH_MODE_PROFILES", {}) + + resp = _dynamic_create(client) + + assert resp.status_code == 400, resp.text + assert "grant-profile pairing" in resp.json()["error"] + + +def test_rfc8693_obo_create_stores_scopes_on_matching_profile( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The mode the pairing exists FOR: a token-exchange deployment accepts + rfc8693_obo and persists its exchange scopes. """ _seed_model_def(storage, definition_id="m1", alias="local", model="m") client = _make_client( @@ -1560,8 +1628,574 @@ def test_entra_obo_allowed_on_rfc8693_profile( client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") _stub_console_mcp(monkeypatch) - resp = _dynamic_create(client, alias="gateway") + resp = _dynamic_create( + client, alias="gateway", auth_mode="rfc8693_obo", obo_scopes="aud-gw openid" + ) + assert resp.status_code == 200, resp.text + row = storage.get_model_definition_by_alias("gateway") + assert row is not None + # Whitespace runs collapse at the write path, matching the registry + # normalizer, so the stored value is a stable mint-cache key component. + assert row["obo_scopes"] == "aud-gw openid" + + +def test_base_url_edit_allowed_on_legacy_entra_obo_rfc8693_row( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A row persisted under the pre-pairing overload keeps accepting + same-pair edits: the pairing lives in the posture tier, which only a + pair change or re-arm reaches. + """ + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="entra_obo", + obo_audience="api://approved", + ) + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + _stub_console_mcp(monkeypatch) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"base_url": "https://other.example/v1"}, + ) + + assert resp.status_code == 200, resp.text + assert storage.get_model_definition("m1")["base_url"] == "https://other.example/v1" + + +def test_create_rejects_scopes_on_non_exchange_mode(storage: SQLiteBackend) -> None: + """The scopes staging guard, create side: a mode that never reads scopes + must not store them for a later flip to inherit. Request-shape, so even + full permissions draw the 400. + """ + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + + resp = _dynamic_create(client, alias="gateway", obo_scopes="aud-gw") + + assert resp.status_code == 400, resp.text + assert "obo_scopes" in resp.json()["error"] + + +def test_update_rejects_new_scopes_on_static_row(storage: SQLiteBackend) -> None: + """Update side of the scopes staging guard, on the row class where no + escalation gate would otherwise run: a static row plus a new scopes value + is refused outright rather than parked. + """ + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client(storage, _make_registry(alias="local", model="m")) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"obo_scopes": "aud-gw"}, + ) + + assert resp.status_code == 400, resp.text + assert "obo_scopes" in resp.json()["error"] + assert storage.get_model_definition("m1")["obo_scopes"] == "" + + +def test_create_rejects_over_length_scopes(storage: SQLiteBackend) -> None: + """Over-length scopes REFUSE rather than truncate: a silently shortened + list changes what the exchange leg requests. (The audience keeps its + truncate posture — allow-list membership backstops it; scopes have no + such list.) The bound measures the CLEANED value — what would actually + be stored — and on the create twin there is no stored residue to echo, + so a changed over-length value always refuses. + """ + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + + resp = _dynamic_create(client, alias="gateway", auth_mode="rfc8693_obo", obo_scopes="s" * 2100) + + assert resp.status_code == 400, resp.text + assert "exceeds" in resp.json()["error"] + assert storage.get_model_definition_by_alias("gateway") is None + + +def test_update_rejects_over_length_scopes(storage: SQLiteBackend) -> None: + """Update side of the over-length refusal: a CHANGED over-length value + (here: the row stores short scopes) is refused, measured on the cleaned + form, and the stored value survives. An over-length ECHO of the row's + own residue is the one non-refusing case — see the residue pins below. + """ + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes="aud-gw", + ) + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"obo_scopes": "s" * 2100}, + ) + + assert resp.status_code == 400, resp.text + assert "exceeds" in resp.json()["error"] + assert storage.get_model_definition("m1")["obo_scopes"] == "aud-gw" + + +def test_over_length_scopes_residue_row_still_disarms( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A DB-direct row carrying over-cap scopes residue still disarms via the + full form echoing its own residue: the echo parses as unchanged (the + column is omitted, the server preserves the stored value), so the + pure-disable carve-out is reachable instead of the over-length refusal + firing before the gate ever saw the disarm. + """ + residue = "s" * 2100 + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes=residue, + ) + client = _make_client(storage, _make_registry(alias="local", model="m")) + _stub_console_mcp(monkeypatch) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={ + "enabled": False, + "auth_mode": "rfc8693_obo", + "obo_audience": "api://approved", + "obo_scopes": residue, + }, + ) + + assert resp.status_code == 200, resp.text + row = storage.get_model_definition("m1") + assert not row["enabled"] + assert row["obo_scopes"] == residue + + +def test_over_length_scopes_residue_row_resaves_unrelated_field( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The same echo rule keeps a residue row editable at all: a + tuning-field save whose full form re-sends the stored over-cap scopes + lands, and the stored value survives byte-identical. + """ + residue = "s" * 2100 + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes=residue, + ) + client = _make_client(storage, _make_registry(alias="local", model="m")) + _stub_console_mcp(monkeypatch) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"temperature": 0.5, "obo_scopes": residue}, + ) + + assert resp.status_code == 200, resp.text + row = storage.get_model_definition("m1") + assert row["temperature"] == 0.5 + assert row["obo_scopes"] == residue + + +def test_over_length_paste_that_cleans_under_cap_is_accepted( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The bound measures the CLEANED value: a paste whose raw length only + exceeds the cap because of control bytes the sanitize strips (terminal + escapes riding a copy-paste) stores its cleaned form instead of drawing + the over-length refusal against characters that were never stored. + """ + # Built programmatically: 20 blocks of 102 'x's + ESC = 2060 raw chars, + # cleaning to 2040 — over the cap raw, under it cleaned. + raw = ("x" * 102 + chr(27)) * 20 + cleaned = raw.replace(chr(27), "") + assert len(raw) > 2048 + assert len(cleaned) <= 2048 + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + _stub_console_mcp(monkeypatch) + + resp = _dynamic_create(client, alias="gateway", auth_mode="rfc8693_obo", obo_scopes=raw) + + assert resp.status_code == 200, resp.text + assert storage.get_model_definition_by_alias("gateway")["obo_scopes"] == cleaned + + +def test_over_cap_residue_capped_rewrite_is_auth_gated( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The capped SPELLING of over-cap DB-direct residue is a real value + change: writing it flips a registry-refused row into a loadable, + mintable one, so it takes the full escalation gate — never the + unchanged-resave fast path. The gate's stored-side baseline is the + UNCAPPED sanitize, so over-cap residue never compares equal to any + storable submission. + """ + from turnstone.core.mcp_oauth import model_obo_cache_server + + residue = "s" * 2100 + capped = "s" * 2048 + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes=residue, + ) + # admin.models alone: the write is auth-gated, refused, and unwritten. + client = _make_client(storage, _make_registry(alias="local", model="m")) + _stub_console_mcp(monkeypatch) + + resp = client.put("/v1/api/admin/model-definitions/m1", json={"obo_scopes": capped}) + + assert resp.status_code == 403, resp.text + assert storage.get_model_definition("m1")["obo_scopes"] == residue + + # With admin.mcp the same write passes the gate, lands, and purges the + # alias's mint-cache rows like any other scopes change. + own_key = model_obo_cache_server("local") + _seed_mint_cache_row(storage, "alice", own_key) + gated = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + gated.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + + resp = gated.put("/v1/api/admin/model-definitions/m1", json={"obo_scopes": capped}) + + assert resp.status_code == 200, resp.text + assert storage.get_model_definition("m1")["obo_scopes"] == capped + assert storage.get_mcp_user_token("alice", own_key) is None + + +def _seed_mint_cache_row(storage: SQLiteBackend, user: str, key: str) -> None: + storage.create_mcp_user_token( + user, + key, + access_token_ct=b"ct", + refresh_token_ct=None, + expires_at=None, + scopes="", + as_issuer="https://issuer.example", + audience="api://approved", + ) + + +def test_scopes_change_purges_the_alias_rows_never_a_siblings( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A scope change purges the definition's OWN identity-keyed rows — + BOTH synthetic prefixes — and can never touch a sibling definition's + rows: the key carries the owning alias, so admin lifecycle on one + definition is invisible to every other (the shared-key over-delete + class is structurally closed). + """ + from turnstone.core.mcp_oauth import model_app_cache_server, model_obo_cache_server + + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes="aud-gw", + ) + own_obo = model_obo_cache_server("local") + own_app = model_app_cache_server("local") + sibling = model_obo_cache_server("sibling") + for key in (own_obo, own_app, sibling): + _seed_mint_cache_row(storage, "alice", key) + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + _stub_console_mcp(monkeypatch) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"obo_scopes": "aud-gw openid"}, + ) + + assert resp.status_code == 200, resp.text + assert storage.get_mcp_user_token("alice", own_obo) is None + assert storage.get_mcp_user_token("alice", own_app) is None + assert storage.get_mcp_user_token("alice", sibling) is not None + + +def test_alias_rename_purges_the_old_alias_rows( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A rename orphans the OLD alias's identity keys outright — nothing + would ever read or overwrite them again — so the update purges them, + exactly as the MCP update purges rows keyed on a renamed server name. + """ + from turnstone.core.mcp_oauth import model_obo_cache_server + + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes="aud-gw", + ) + old_key = model_obo_cache_server("local") + _seed_mint_cache_row(storage, "alice", old_key) + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + _stub_console_mcp(monkeypatch) + + resp = client.put("/v1/api/admin/model-definitions/m1", json={"alias": "renamed"}) + + assert resp.status_code == 200, resp.text + assert storage.get_mcp_user_token("alice", old_key) is None + + +def test_delete_purges_mint_cache_rows( + storage: SQLiteBackend, +) -> None: + """Deleting a definition purges its identity-keyed mint-cache rows — + both prefixes — before the row goes away, like the MCP delete purges + its server-name rows; a sibling definition's rows survive.""" + from turnstone.core.mcp_oauth import model_app_cache_server, model_obo_cache_server + + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes="aud-gw", + ) + own_obo = model_obo_cache_server("local") + own_app = model_app_cache_server("local") + sibling = model_obo_cache_server("sibling") + for key in (own_obo, own_app, sibling): + _seed_mint_cache_row(storage, "alice", key) + client = _make_client(storage, _make_registry(alias="local", model="m")) + + resp = client.delete("/v1/api/admin/model-definitions/m1") + + assert resp.status_code == 200, resp.text + assert storage.get_mcp_user_token("alice", own_obo) is None + assert storage.get_mcp_user_token("alice", own_app) is None + assert storage.get_mcp_user_token("alice", sibling) is not None + + +def test_purge_partial_failure_still_purges_the_other_prefix( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The both-prefixes contract holds under partial storage failure: a + transient error deleting one prefix's rows must not abort the other's + delete (each prefix purges in its own best-effort arm). + """ + from turnstone.console.server import _purge_model_mint_cache + from turnstone.core.mcp_oauth import model_app_cache_server, model_obo_cache_server + + obo_key = model_obo_cache_server("local") + app_key = model_app_cache_server("local") + for key in (obo_key, app_key): + _seed_mint_cache_row(storage, "alice", key) + real_delete = storage.delete_mcp_oauth_rows_by_server_name + + def flaky(server_name: str) -> int: + if server_name == obo_key: + raise RuntimeError("transient storage error") + return real_delete(server_name) + + monkeypatch.setattr(storage, "delete_mcp_oauth_rows_by_server_name", flaky) + + _purge_model_mint_cache(storage, "m1", "local") + + assert storage.get_mcp_user_token("alice", obo_key) is not None + assert storage.get_mcp_user_token("alice", app_key) is None + + +def test_mode_flip_away_keeps_unchanged_scopes_residue( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Flipping an exchange-mode row to entra_obo with the full form re-sending + its stored scopes is not a staging violation (VALUE CHANGE only), so the + flip lands and the residue stays inert — while a DIFFERENT value on the + now-non-exchange row is refused. + """ + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes="aud-gw", + ) + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + _stub_console_mcp(monkeypatch) + + flip = client.put( + "/v1/api/admin/model-definitions/m1", + json={ + "auth_mode": "entra_obo", + "obo_audience": "api://approved", + "obo_scopes": "aud-gw", + }, + ) + assert flip.status_code == 200, flip.text + assert storage.get_model_definition("m1")["obo_scopes"] == "aud-gw" + + changed = client.put( + "/v1/api/admin/model-definitions/m1", + json={"obo_scopes": "aud-other"}, + ) + assert changed.status_code == 400, changed.text + assert storage.get_model_definition("m1")["obo_scopes"] == "aud-gw" + + +def test_create_normalizes_tab_separated_scopes( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Whitespace separators collapse BEFORE control-char cleaning, so a + pasted tab-separated scope list stores as distinct scopes — cleaning + first would delete the tab and CONCATENATE them into one bogus scope + the IdP refuses. + """ + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + client = _make_client( + storage, _make_registry(alias="local", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + _stub_console_mcp(monkeypatch) + + resp = _dynamic_create( + client, alias="gateway", auth_mode="rfc8693_obo", obo_scopes="aud-gw\topenid" + ) + + assert resp.status_code == 200, resp.text + assert storage.get_model_definition_by_alias("gateway")["obo_scopes"] == "aud-gw openid" + + +def test_raw_stored_scopes_residue_resave_and_disarm_stay_open( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A DB-direct stored scopes value with interior whitespace runs compares + equal to its own collapsed full-form re-save — both sides go through the + one normalizer — so neither the ordinary re-save nor the admin.models + disarm misreads residue as a staged change. + """ + for definition_id, alias in (("m1", "local"), ("m2", "other")): + _seed_model_def( + storage, + definition_id=definition_id, + alias=alias, + model="m", + auth_mode="entra_obo", + obo_audience="api://approved", + obo_scopes="aud-gw openid", + ) + _stub_console_mcp(monkeypatch) + + resave_client = _make_client( + storage, + _make_registry(alias="local", model="m", extras={"other": "m"}), + perms="admin.models,admin.mcp", + ) + resave = resave_client.put( + "/v1/api/admin/model-definitions/m1", + json={ + "auth_mode": "entra_obo", + "obo_audience": "api://approved", + "obo_scopes": "aud-gw openid", + }, + ) + assert resave.status_code == 200, resave.text + + disarm_client = _make_client( + storage, _make_registry(alias="local", model="m", extras={"other": "m"}) + ) + disarm = disarm_client.put( + "/v1/api/admin/model-definitions/m2", + json={"enabled": False, "obo_scopes": "aud-gw openid"}, + ) + assert disarm.status_code == 200, disarm.text + assert storage.get_model_definition("m2")["enabled"] is False + + +def test_pure_disable_with_stored_scopes_stays_carved_out( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stored scopes never block de-escalation: the lone enabled-off submit on + an exchange-mode row is still the pure-disable carve-out (admin.models, + no validator). + """ + _seed_model_def( + storage, + definition_id="m1", + alias="local", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://approved", + obo_scopes="aud-gw", + ) + client = _make_client(storage, _make_registry(alias="local", model="m")) + _stub_console_mcp(monkeypatch) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"enabled": False}, + ) + + assert resp.status_code == 200, resp.text + row = storage.get_model_definition("m1") + assert row["enabled"] is False and row["obo_scopes"] == "aud-gw" def test_unchanged_dynamic_auth_fields_do_not_require_admin_mcp( @@ -1980,6 +2614,38 @@ def test_keyless_reenable_of_dynamic_row_returns_503( assert not storage.get_model_definition("m1")["enabled"] +def test_legacy_cross_profile_row_reenables_unchanged( + storage: SQLiteBackend, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pairing is a pair-CHOOSE rule: a row persisted under the other grant + dialect disables AND re-enables untouched — re-arming keeps the row's + standing, and its mint refuses at runtime with grant_profile_mismatch + (fallback-eligible by ruling) rather than the shelf holding it hostage. + """ + _seed_model_def( + storage, + definition_id="m1", + alias="gw", + model="m", + auth_mode="entra_obo", + obo_audience="api://approved", + ) + client = _make_client( + storage, _make_registry(alias="gw", model="m"), perms="admin.models,admin.mcp" + ) + client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693") + _stub_console_mcp(monkeypatch) + + off = client.put("/v1/api/admin/model-definitions/m1", json={"enabled": False}) + assert off.status_code == 200, off.text + assert not storage.get_model_definition("m1")["enabled"] + + on = client.put("/v1/api/admin/model-definitions/m1", json={"enabled": True}) + assert on.status_code == 200, on.text + assert storage.get_model_definition("m1")["enabled"] + + def test_keyless_pure_disable_still_succeeds(storage: SQLiteBackend) -> None: """Posture-on-arming must not leak into de-escalation: the posture tier guards what a write ARMS, and a disable arms nothing. @@ -3034,6 +3700,7 @@ def test_model_definition_schema_auth_classification(storage: SQLiteBackend) -> "enabled", "auth_mode", "obo_audience", + "obo_scopes", } == MODEL_DEFINITION_MUTABLE - MODEL_AUTH_NEUTRAL_FIELDS @@ -3067,6 +3734,7 @@ def test_every_mutable_column_probes_its_classification( "replay_reasoning_to_model": True, "auth_mode": "entra_app", "obo_audience": "api://other", + "obo_scopes": "aud-gw openid", } assert set(probes) == set(MODEL_DEFINITION_MUTABLE), ( "a mutable column has no probe value — add one so its classification " @@ -3082,7 +3750,10 @@ def test_every_mutable_column_probes_its_classification( alias=f"gw-{column}", model="m", enabled=(column != "enabled"), - auth_mode="entra_obo", + # The scopes probe seeds the one mode that accepts a scopes + # value, so the 403 (permission enforced) is what the probe + # observes rather than the earlier staging-shape 400. + auth_mode="rfc8693_obo" if column == "obo_scopes" else "entra_obo", obo_audience="api://approved", ) client = _make_client(storage, _make_registry(alias=f"gw-{column}", model="m")) diff --git a/tests/test_app_js.py b/tests/test_app_js.py index 2abd7dfc..cbaec8f0 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -3662,3 +3662,52 @@ def test_coordinator_tool_output_pres_are_focusable() -> None: "the coordinator retry sweep must remove a bar emptied of its last " "control — screen readers announce empty 'Message actions' toolbars" ) + + +def test_admin_js_auth_mode_fallbacks_match_registry() -> None: + """The model shelf's fail-open fallback literals track the server maps. + + At runtime the served constraints are authoritative and these hand-kept + fallbacks only cover a missing/failed fetch — but a drifted fallback + silently misclassifies exactly when the authority is unavailable, so + each one is pinned against the registry's classification maps. + """ + from turnstone.core.model_registry import ( + DYNAMIC_MODEL_AUTH_MODES, + MODEL_AUTH_MODE_PROFILES, + MODEL_AUTH_MODES, + SCOPES_MODEL_AUTH_MODES, + ) + + body = _CONSOLE_ADMIN_JS.read_text(encoding="utf-8") + # The ONE hoisted fallback pairing map (_AUTH_MODE_FALLBACK_PROFILES): + # _syncModelAuthFields uses it directly and _isDynamicAuthMode's + # fallback list derives from its keys, so pinning the map's entries + # covers both consumers. + for mode, profile in MODEL_AUTH_MODE_PROFILES.items(): + assert f'{mode}: "{profile}"' in body, f"fallback pairing for {mode} missing/drifted" + assert "Object.keys(_AUTH_MODE_FALLBACK_PROFILES)" in body, ( + "the dynamic-mode fallback must derive from the pairing map's keys" + ) + # Every registry-classified dynamic mode must appear as a map key. + for mode in DYNAMIC_MODEL_AUTH_MODES: + assert f'{mode}: "' in body, f"dynamic-mode fallback missing {mode}" + # The scopes fallback stays its own literal array (not derivable from + # the pairing map): every scopes mode must appear as a quoted literal. + for mode in SCOPES_MODEL_AUTH_MODES: + assert f'"{mode}"' in body, f"scopes-mode fallback missing {mode}" + # Same contract for the app-identity fallback (the model list's auth + # badge derives per-user vs deployment from it), and the badge site + # must classify via the shared predicates, never a hand list. + from turnstone.core.model_registry import APP_IDENTITY_MODEL_AUTH_MODES + + for mode in APP_IDENTITY_MODEL_AUTH_MODES: + assert f'"{mode}"' in body, f"app-identity fallback missing {mode}" + assert body.count("_isAppIdentityAuthMode") >= 2, ( + "the model-list badge must classify via the shared app-identity predicate" + ) + # And the shelf's select ships an option per registry mode, so no mode + # depends on the injected-option skew path on a current page. + index_body = _CONSOLE_INDEX.read_text(encoding="utf-8") + for mode in sorted(MODEL_AUTH_MODES): + assert f'value="{mode}"' in index_body, f"index.html option missing {mode}" diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py index f768d5fc..c0d790a7 100644 --- a/tests/test_auth_identity.py +++ b/tests/test_auth_identity.py @@ -186,6 +186,12 @@ class TestRequiredScope: assert required_scope("POST", "/api/admin/users") == "approve" assert required_scope("DELETE", "/api/admin/users/abc") == "approve" + def test_internal_model_status_is_admin_classified(self): + # The payload carries per-alias backend-auth configuration (mode, + # audience, exchange scopes); it must never fall to the read default. + assert required_scope("GET", "/v1/api/_internal/model-status") == "approve" + assert required_scope("GET", "/api/_internal/model-status") == "approve" + def test_versioned_path(self): assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write" assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve" diff --git a/tests/test_model_provider_obo.py b/tests/test_model_provider_obo.py index bb9de9cf..e5052fdc 100644 --- a/tests/test_model_provider_obo.py +++ b/tests/test_model_provider_obo.py @@ -60,6 +60,10 @@ if TYPE_CHECKING: USER = "user-1" AUDIENCE = "https://models.example.com" +# The owning model-definition alias the mint's cache and cause records key +# on (identity-keyed, like mcp_servers.name for MCP rows). +MODEL_ALIAS = "gw-model" +APP_ALIAS = "gw-app-model" _MIGRATIONS_DIR = str( Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations" @@ -150,6 +154,51 @@ class TestMigration068: engine.dispose() +class TestMigration069: + def test_upgrade_defaults_preexisting_rows_to_empty_scopes(self, tmp_path: Path) -> None: + db_path = tmp_path / "069-up.db" + cfg = _alembic_cfg(db_path) + command.upgrade(cfg, "068") + engine = sa.create_engine(f"sqlite:///{db_path}") + try: + with engine.begin() as conn: + conn.execute( + sa.text( + "INSERT INTO model_definitions " + "(definition_id, alias, model, created, updated) " + "VALUES ('d1', 'gpt', 'gpt-5', " + "'2026-01-01T00:00:00', '2026-01-01T00:00:00')" + ) + ) + command.upgrade(cfg, "069") + with engine.connect() as conn: + row = conn.execute( + sa.text("SELECT obo_scopes FROM model_definitions WHERE definition_id = 'd1'") + ).fetchone() + assert row is not None and row[0] == "" + finally: + engine.dispose() + + def test_downgrade_then_upgrade_round_trip(self, tmp_path: Path) -> None: + db_path = tmp_path / "069-roundtrip.db" + cfg = _alembic_cfg(db_path) + command.upgrade(cfg, "069") + command.downgrade(cfg, "068") + engine = sa.create_engine(f"sqlite:///{db_path}") + try: + cols = {c["name"] for c in sa.inspect(engine).get_columns("model_definitions")} + assert "obo_scopes" not in cols + finally: + engine.dispose() + command.upgrade(cfg, "069") + engine = sa.create_engine(f"sqlite:///{db_path}") + try: + cols = {c["name"] for c in sa.inspect(engine).get_columns("model_definitions")} + assert "obo_scopes" in cols + finally: + engine.dispose() + + # --------------------------------------------------------------------------- # Storage + admin-load round-trip # --------------------------------------------------------------------------- @@ -220,6 +269,62 @@ class TestModelDefinitionStorage: with pytest.raises(ModelAuthConfigError, match="invalid auth_mode"): load_model_registry(storage=storage, allow_empty=True) + def test_rfc8693_row_round_trips_scopes_into_registry(self, storage: SQLiteBackend) -> None: + storage.create_model_definition( + definition_id="d5", + alias="tf-kc", + model="vmg/opus", + auth_mode="rfc8693_obo", + obo_audience=AUDIENCE, + obo_scopes="aud-gw openid", + ) + row = storage.get_model_definition_by_alias("tf-kc") + assert row is not None and row["obo_scopes"] == "aud-gw openid" + registry = load_model_registry(storage=storage, allow_empty=True) + cfg = registry.get_config("tf-kc") + assert cfg.auth_mode == "rfc8693_obo" + # The registry normalizer collapses whitespace runs so the value is a + # stable mint-cache key component. + assert cfg.obo_scopes == "aud-gw openid" + + def test_rfc8693_mode_requires_audience(self, storage: SQLiteBackend) -> None: + storage.create_model_definition( + definition_id="d6", + alias="no-aud", + model="m", + auth_mode="rfc8693_obo", + ) + with pytest.raises(ModelAuthConfigError, match="requires obo_audience"): + load_model_registry(storage=storage, allow_empty=True) + + def test_scopes_residue_on_entra_mode_still_loads(self, storage: SQLiteBackend) -> None: + """A stored scopes value on a mode that never reads it must not make + the alias unloadable — the dispatch keeps it inert, mirroring the + stale-audience-on-static tolerance. + """ + storage.create_model_definition( + definition_id="d7", + alias="residue", + model="m", + auth_mode="entra_obo", + obo_audience=AUDIENCE, + obo_scopes="stale-scope", + ) + registry = load_model_registry(storage=storage, allow_empty=True) + assert registry.get_config("residue").obo_scopes == "stale-scope" + + def test_runtime_scopes_reject_control_characters(self, storage: SQLiteBackend) -> None: + storage.create_model_definition( + definition_id="bad-scopes", + alias="bad-scopes", + model="m", + auth_mode="rfc8693_obo", + obo_audience=AUDIENCE, + obo_scopes="aud-gw\x01injected", + ) + with pytest.raises(ModelAuthConfigError, match="obo_scopes contains control"): + load_model_registry(storage=storage, allow_empty=True) + def test_runtime_audience_rejects_control_characters( self, storage: SQLiteBackend, @@ -324,10 +429,11 @@ def _seed_credential(state: SimpleNamespace, *, refresh_token: str = "rt-1") -> def _mint(state: SimpleNamespace, **kwargs: Any) -> Any: + kwargs.setdefault("alias", MODEL_ALIAS) + kwargs.setdefault("audience", AUDIENCE) + async def _run() -> Any: - return await mint_obo_access_token( - app_state=state, user_id=USER, audience=AUDIENCE, **kwargs - ) + return await mint_obo_access_token(app_state=state, user_id=USER, **kwargs) return asyncio.run(_run()) @@ -386,8 +492,9 @@ class TestMintOboAccessToken: assert _mint(node_a) == "at-minted" assert client.post.call_count == 1 - # Persisted as a "cache, not custody" row (refresh_token NULL), decodable. - cache_server = f"__model_obo__:{AUDIENCE}" + # Persisted as a "cache, not custody" row (refresh_token NULL), + # decodable, identity-keyed on the owning alias. + cache_server = f"__model_obo__:{MODEL_ALIAS}" raw = storage.get_mcp_user_token(USER, cache_server) assert raw is not None and raw["refresh_token_ct"] is None plain = node_a.mcp_token_store.get_user_token(USER, cache_server) @@ -472,7 +579,7 @@ class TestMintOboAccessToken: monkeypatch.setattr(mcp_oauth_module, "_MODEL_OBO_MISSING_CRED_WARNED", user_full) monkeypatch.setattr(mcp_oauth_module, "_MODEL_MINT_MISCONFIG_WARNED", operator_fresh) mcp_oauth_module._warn_model_mint_misconfig_once( - "model_obo.oidc_not_enabled", "api://aud", "u-any" + "model_obo.oidc_not_enabled", "api://aud", "u-any", cause_key="api://aud" ) assert operator_fresh == {"model_obo.oidc_not_enabled:api://aud"} @@ -480,12 +587,14 @@ class TestMintOboAccessToken: user_fresh: set[tuple[str, str]] = set() monkeypatch.setattr(mcp_oauth_module, "_MODEL_MINT_MISCONFIG_WARNED", operator_full) monkeypatch.setattr(mcp_oauth_module, "_MODEL_OBO_MISSING_CRED_WARNED", user_fresh) - mcp_oauth_module._warn_model_obo_missing_credential_once("api://aud", "u-new") + mcp_oauth_module._warn_model_obo_missing_credential_once( + "api://aud", "u-new", cause_key="api://aud" + ) assert user_fresh == {("u-new", "api://aud")} def test_success_clears_only_the_minting_users_cause(self, storage: SQLiteBackend) -> None: - """The last-cause record is keyed per (prefix, audience, user).""" - from turnstone.core.mcp_oauth import model_mint_refusal_cause + """The last-cause record is keyed per (prefix, alias, user).""" + from turnstone.core.mcp_oauth import model_mint_refusal_cause, model_obo_cache_server client = MagicMock(spec=httpx.AsyncClient) client.post = AsyncMock( @@ -496,19 +605,29 @@ class TestMintOboAccessToken: state.mcp_token_store.upsert_oidc_credential("bob", ISSUER, refresh_token="rt-bob") async def _mint_as(user: str) -> Any: - return await mint_obo_access_token(app_state=state, user_id=user, audience=AUDIENCE) + return await mint_obo_access_token( + app_state=state, user_id=user, alias=MODEL_ALIAS, audience=AUDIENCE + ) assert asyncio.run(_mint_as("alice")) is None - assert model_mint_refusal_cause("model_obo", AUDIENCE, "alice") == "missing_credential" + assert ( + model_mint_refusal_cause("model_obo", model_obo_cache_server(MODEL_ALIAS), "alice") + == "missing_credential" + ) assert asyncio.run(_mint_as("bob")) == "at-bob" - assert model_mint_refusal_cause("model_obo", AUDIENCE, "bob") == "" - assert model_mint_refusal_cause("model_obo", AUDIENCE, "alice") == "missing_credential" + assert ( + model_mint_refusal_cause("model_obo", model_obo_cache_server(MODEL_ALIAS), "bob") == "" + ) + assert ( + model_mint_refusal_cause("model_obo", model_obo_cache_server(MODEL_ALIAS), "alice") + == "missing_credential" + ) def test_cooldown_window_keeps_the_recorded_cause(self, storage: SQLiteBackend) -> None: """The record persists across cooldown short-circuits: only the recording user's successful mint clears a cause.""" - from turnstone.core.mcp_oauth import model_mint_refusal_cause + from turnstone.core.mcp_oauth import model_mint_refusal_cause, model_obo_cache_server client = MagicMock(spec=httpx.AsyncClient) client.post = AsyncMock( @@ -518,11 +637,13 @@ class TestMintOboAccessToken: state.mcp_token_store.upsert_oidc_credential("bob", ISSUER, refresh_token="rt-bob") async def _mint_as(user: str) -> Any: - return await mint_obo_access_token(app_state=state, user_id=user, audience=AUDIENCE) + return await mint_obo_access_token( + app_state=state, user_id=user, alias=MODEL_ALIAS, audience=AUDIENCE + ) # First refusal records the cause and arms alice's cooldown. assert asyncio.run(_mint_as("alice")) is None - # Another user's success on the shared audience must not disturb it. + # Another user's success on the shared alias must not disturb it. assert asyncio.run(_mint_as("bob")) == "at-bob" posts_after_bob = client.post.call_count @@ -530,7 +651,10 @@ class TestMintOboAccessToken: # short-circuits (no IdP traffic) and the cause survives. assert asyncio.run(_mint_as("alice")) is None assert client.post.call_count == posts_after_bob - assert model_mint_refusal_cause("model_obo", AUDIENCE, "alice") == "missing_credential" + assert ( + model_mint_refusal_cause("model_obo", model_obo_cache_server(MODEL_ALIAS), "alice") + == "missing_credential" + ) def test_oidc_disabled_returns_none_no_http(self, storage: SQLiteBackend) -> None: client = MagicMock(spec=httpx.AsyncClient) @@ -613,6 +737,380 @@ class TestMintOboAccessToken: assert _mint(state) is None assert client.post.call_count == 1 + def test_rfc8693_scoped_exchange_mints_and_caches(self, storage: SQLiteBackend) -> None: + """The #955 wire-through: the exchange leg requests the caller's + scopes, the row keys on the OWNING ALIAS, and the requested scopes + land in the row's legible ``scopes`` column for the freshness gate. + """ + from turnstone.core.mcp_oauth import model_obo_cache_server + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + _mk_response(200, {"access_token": "subject-at", "expires_in": 300}), + _mk_response(200, {"access_token": "exchanged-at", "expires_in": 3600}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + + token = _mint(state, scopes="aud-gw openid", grant_leg="rfc8693") + + assert token == "exchanged-at" + assert client.post.call_count == 2 + exchange = client.post.call_args_list[1] + assert exchange.kwargs["data"]["scope"] == "aud-gw openid" + assert exchange.kwargs["data"]["audience"] == AUDIENCE + + plain = state.mcp_token_store.get_user_token(USER, model_obo_cache_server(MODEL_ALIAS)) + assert plain is not None + assert plain["access_token"] == "exchanged-at" + assert plain["scopes"] == "aud-gw openid" + assert plain["audience"] == AUDIENCE + # Identity keys: another alias holds no row — one owner per key. + assert storage.get_mcp_user_token(USER, model_obo_cache_server("other-model")) is None + + # Warm re-mint with the same scopes serves the cache, zero IdP calls. + assert _mint(state, scopes="aud-gw openid", grant_leg="rfc8693") == "exchanged-at" + assert client.post.call_count == 2 + + def test_changed_scopes_refuse_the_stale_row_and_overwrite_in_place( + self, storage: SQLiteBackend + ) -> None: + """The freshness gate compares the row's stored scopes against the + CURRENT dispatch scopes, so a re-scoped alias never serves the + superseded bearer — the next mint overwrites the SAME identity key + in place, leaving no stranded row behind. + """ + from turnstone.core.mcp_oauth import model_obo_cache_server + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + _mk_response(200, {"access_token": "subject-1", "expires_in": 300}), + _mk_response(200, {"access_token": "wide-at", "expires_in": 3600}), + _mk_response(200, {"access_token": "subject-2", "expires_in": 300}), + _mk_response(200, {"access_token": "narrow-at", "expires_in": 3600}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + + assert _mint(state, scopes="aud-gw wide", grant_leg="rfc8693") == "wide-at" + # The operator narrows the alias's scopes: the wide-scope row fails + # the freshness compare (no serving), a fresh mint runs, and the + # one identity-keyed row now holds the narrow bearer. + assert _mint(state, scopes="aud-gw", grant_leg="rfc8693") == "narrow-at" + assert client.post.call_count == 4 + plain = state.mcp_token_store.get_user_token(USER, model_obo_cache_server(MODEL_ALIAS)) + assert plain is not None + assert plain["access_token"] == "narrow-at" + assert plain["scopes"] == "aud-gw" + + def test_scopes_whitespace_runs_hit_the_same_cache_row(self, storage: SQLiteBackend) -> None: + """Entry-path normalization: a caller spelling the scopes with + different interior whitespace must hit the same cache row, not + re-mint — the row's stored scopes and the freshness compare's + current side both pass through the one normalizer. + """ + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + _mk_response(200, {"access_token": "subject-at", "expires_in": 300}), + _mk_response(200, {"access_token": "exchanged-at", "expires_in": 3600}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + + assert _mint(state, scopes="aud-gw openid", grant_leg="rfc8693") == "exchanged-at" + assert _mint(state, scopes=" aud-gw openid ", grant_leg="rfc8693") == "exchanged-at" + assert client.post.call_count == 2 + + def test_grant_leg_mismatch_refuses_before_idp(self, storage: SQLiteBackend) -> None: + """A mode's pinned leg contradicting the deployment profile refuses + with the recorded cause and ZERO IdP traffic, in both directions. + """ + from turnstone.core.mcp_oauth import model_mint_refusal_cause, model_obo_cause_key + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock() + state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config()) + _seed_credential(state) + + assert _mint(state, grant_leg="rfc8693") is None + assert client.post.call_count == 0 + assert ( + model_mint_refusal_cause( + "model_obo", model_obo_cause_key(MODEL_ALIAS, grant_leg="rfc8693"), USER + ) + == "grant_profile_mismatch" + ) + + rfc_state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + assert _mint(rfc_state, grant_leg="entra") is None + assert client.post.call_count == 0 + assert ( + model_mint_refusal_cause( + "model_obo", model_obo_cause_key(MODEL_ALIAS, grant_leg="entra"), USER + ) + == "grant_profile_mismatch" + ) + + def test_identity_cache_keys_stay_index_safe_and_disjoint(self) -> None: + """Console-written aliases (≤64 ASCII) key literally; a pathological + DB-direct alias — over-bound multibyte, or control-embedded — still + yields an index-safe key (PostgreSQL btree tuple limit: 2704 bytes) + under the builder's OWN prefix, distinct per alias, and a control + character can never forge the digest spelling's separator shape. + """ + from turnstone.core.mcp_oauth import ( + MODEL_APP_CACHE_PREFIX, + MODEL_OBO_CACHE_PREFIX, + model_app_cache_server, + model_obo_cache_server, + ) + + # The whole console-legal range keys literally. + assert model_obo_cache_server("gw.model-1") == f"{MODEL_OBO_CACHE_PREFIX}gw.model-1" + assert model_app_cache_server("gw.model-1") == f"{MODEL_APP_CACHE_PREFIX}gw.model-1" + # Same alias, different mode prefixes: distinct rows by construction. + assert model_obo_cache_server("gw.model-1") != model_app_cache_server("gw.model-1") + + # Pathological DB-direct aliases: over-bound multibyte collapses to + # the digest spelling, still under the index bound, prefix kept, + # distinct per alias. + multibyte = "ü" * 3000 + mb_key = model_obo_cache_server(multibyte) + assert len(mb_key.encode("utf-8")) < 2704 + assert mb_key.startswith(MODEL_OBO_CACHE_PREFIX) + assert mb_key != model_obo_cache_server("ö" * 3000) + app_key = model_app_cache_server(multibyte) + assert len(app_key.encode("utf-8")) < 2704 + assert app_key.startswith(MODEL_APP_CACHE_PREFIX) + + # Control characters strip at the key build, so a crafted alias can + # never spell the digest form's separator-after-prefix shape and + # alias another identity's bounded key. + forged = chr(0x1F) + "a" * 48 + assert model_obo_cache_server(forged) == f"{MODEL_OBO_CACHE_PREFIX}" + "a" * 48 + assert chr(0x1F) not in model_obo_cache_server(forged) + assert chr(0x1F) not in model_app_cache_server(forged) + + def test_scopes_without_exchange_leg_is_a_caller_error(self, storage: SQLiteBackend) -> None: + """Only the token-exchange leg reads scopes; passing them without + pinning that leg is a dispatch bug at the call site, not an operator + state, so it raises instead of returning the fallback-eligible None. + """ + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock() + state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config()) + _seed_credential(state) + with pytest.raises(ValueError, match="grant_leg='rfc8693'"): + _mint(state, scopes="aud-gw") + with pytest.raises(ValueError, match="grant_leg='rfc8693'"): + _mint(state, scopes="aud-gw", grant_leg="entra") + assert client.post.call_count == 0 + + def test_over_length_scopes_is_a_caller_error(self, storage: SQLiteBackend) -> None: + """Every production path bounds scopes at the registry/console before + the mint sees them, so an over-cap value here is a raw call site's + bug — raised as the dispatch contract error, never silently sliced + into a narrower privilege request than the caller asked for. + """ + from turnstone.core.mcp_oauth import MintDispatchContractError + from turnstone.core.model_registry import MODEL_AUTH_TEXT_MAX_LEN + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock() + state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config()) + _seed_credential(state) + with pytest.raises(MintDispatchContractError, match=str(MODEL_AUTH_TEXT_MAX_LEN)): + _mint(state, scopes="s" * (MODEL_AUTH_TEXT_MAX_LEN + 1), grant_leg="rfc8693") + assert client.post.call_count == 0 + + def test_sibling_aliases_on_one_audience_keep_separate_causes( + self, storage: SQLiteBackend + ) -> None: + """The refusal-cause record keys on the OWNING ALIAS, so two + definitions fronting the same gateway audience are separate mint + identities end to end: one's successful mint never clears — or + overwrites — the other's recorded cause. + """ + from turnstone.core.mcp_oauth import model_mint_refusal_cause, model_obo_cause_key + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + # Broken sibling: refresh lands, exchange refused. + _mk_response(200, {"access_token": "subject-at", "expires_in": 300}), + _mk_response(400, {"error": "invalid_request"}), + # Healthy sibling: both legs succeed. + _mk_response(200, {"access_token": "subject-at2", "expires_in": 300}), + _mk_response(200, {"access_token": "exchanged", "expires_in": 3600}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + + assert _mint(state, alias="broken-model", scopes="aud-gw", grant_leg="rfc8693") is None + broken_key = model_obo_cause_key("broken-model", "rfc8693") + assert model_mint_refusal_cause("model_obo", broken_key, USER) == "mint_failed" + + assert _mint(state, alias="healthy-model", grant_leg="rfc8693") == "exchanged" + # The sibling's success cleared only ITS key; the broken record holds. + assert model_mint_refusal_cause("model_obo", broken_key, USER) == "mint_failed" + assert ( + model_mint_refusal_cause( + "model_obo", model_obo_cause_key("healthy-model", grant_leg="rfc8693"), USER + ) + == "" + ) + + def test_shared_audience_mode_variants_keep_separate_causes( + self, storage: SQLiteBackend + ) -> None: + """The refusal-cause record also carries the pinned leg, so two + mode-variants sharing an audience never overwrite each other's + recorded cause — the leg axis of the scope-variant pin above. + """ + from turnstone.core.mcp_oauth import model_mint_refusal_cause, model_obo_cause_key + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + # rfc8693-leg mint: refresh lands, exchange refused. + _mk_response(200, {"access_token": "subject-at", "expires_in": 300}), + _mk_response(400, {"error": "invalid_request"}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + + assert _mint(state, grant_leg="rfc8693") is None + rfc_key = model_obo_cause_key(MODEL_ALIAS, grant_leg="rfc8693") + assert model_mint_refusal_cause("model_obo", rfc_key, USER) == "mint_failed" + + # An entra-leg mint under the SAME alias (an edit history crossing + # modes) refuses before the IdP — and must stamp only ITS leg's key, + # never the rfc8693 record. + assert _mint(state, grant_leg="entra") is None + assert model_mint_refusal_cause("model_obo", rfc_key, USER) == "mint_failed" + assert ( + model_mint_refusal_cause( + "model_obo", model_obo_cause_key(MODEL_ALIAS, grant_leg="entra"), USER + ) + == "grant_profile_mismatch" + ) + + def test_config_repair_clears_the_cooldown_immediately(self, storage: SQLiteBackend) -> None: + """Cooldown keys on (alias, shape), not the alias alone: a mint + failure arms the cooldown for the shape that failed, and an + operator's config repair — a different audience/scopes — is a clean + slate whose first retry mints immediately. The in-process cooldown + is per node and the console purge reaches only DB rows, so without + the shape axis a fail-closed deployment would keep failing user + turns for the full window after the fix. + """ + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + _mk_response(400, {"error": "invalid_grant", "error_description": "bad aud"}), + _mk_response(200, {"access_token": "at-fixed", "expires_in": 3600}), + ] + ) + state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config()) + _seed_credential(state) + + # The misconfigured audience fails and arms the cooldown. + assert _mint(state, audience="api://wrong") is None + assert client.post.call_count == 1 + # Same shape inside the window: short-circuit, zero IdP traffic. + assert _mint(state, audience="api://wrong") is None + assert client.post.call_count == 1 + # The repaired audience is a different shape: mints on the FIRST try. + assert _mint(state, audience="api://right") == "at-fixed" + assert client.post.call_count == 2 + + def test_broken_alias_cooldown_does_not_suppress_sibling_alias( + self, storage: SQLiteBackend + ) -> None: + """Cooldown arms on the identity cache key, so a broken alias cannot + suppress a sibling alias sharing its gateway audience. + """ + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + # Broken alias: refresh leg lands, exchange leg is refused. + _mk_response(200, {"access_token": "subject-at", "expires_in": 300}), + _mk_response(400, {"error": "invalid_request"}), + # Sibling alias afterwards: both legs succeed. + _mk_response(200, {"access_token": "subject-at2", "expires_in": 300}), + _mk_response(200, {"access_token": "exchanged-at", "expires_in": 3600}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + + assert _mint(state, alias="broken-model", scopes="aud-gw", grant_leg="rfc8693") is None + # The broken alias is in cooldown; the sibling still mints. + assert _mint(state, alias="healthy-model", grant_leg="rfc8693") == "exchanged-at" + assert client.post.call_count == 4 + # And the broken alias's cooldown still holds. + assert _mint(state, alias="broken-model", scopes="aud-gw", grant_leg="rfc8693") is None + assert client.post.call_count == 4 + + def test_cause_record_map_evicts_least_recently_stamped_at_cap(self) -> None: + """The cause map is readback state, not a log-dedup set: when full it + evicts the LEAST-RECENTLY-STAMPED record and always records the + newest refusal. Recency is stamp order, not first-insertion order — + a re-stamped record moves to the newest position, so the hottest + record (the one an operator is actively debugging) is evicted last, + never first. + """ + from turnstone.core import mcp_oauth as mcp_oauth_module + + for i in range(mcp_oauth_module._CAUSE_RECORD_CAP): + mcp_oauth_module._record_mint_refusal_cause("model_obo", f"k{i}", "u", "c") + assert len(mcp_oauth_module._MODEL_MINT_LAST_CAUSE) == mcp_oauth_module._CAUSE_RECORD_CAP + # Re-stamp the OLDEST record: dict overwrite alone would leave it at + # its original insertion slot and the next eviction would hit it. + mcp_oauth_module._record_mint_refusal_cause("model_obo", "k0", "u", "hot") + mcp_oauth_module._record_mint_refusal_cause("model_obo", "k-new", "u", "newest") + assert len(mcp_oauth_module._MODEL_MINT_LAST_CAUSE) == mcp_oauth_module._CAUSE_RECORD_CAP + assert mcp_oauth_module.model_mint_refusal_cause("model_obo", "k-new", "u") == "newest" + # The re-stamped record survives; the least-recently-stamped (k1) went. + assert mcp_oauth_module.model_mint_refusal_cause("model_obo", "k0", "u") == "hot" + assert mcp_oauth_module.model_mint_refusal_cause("model_obo", "k1", "u") == "" + # --------------------------------------------------------------------------- # mint_app_access_token — app-identity (client-credentials) mint @@ -620,8 +1118,11 @@ class TestMintOboAccessToken: def _mint_app(state: SimpleNamespace, **kwargs: Any) -> Any: + kwargs.setdefault("alias", APP_ALIAS) + kwargs.setdefault("audience", AUDIENCE) + async def _run() -> Any: - return await mint_app_access_token(app_state=state, audience=AUDIENCE, **kwargs) + return await mint_app_access_token(app_state=state, **kwargs) return asyncio.run(_run()) @@ -646,10 +1147,11 @@ class TestMintAppAccessToken: "client_secret": "csecret", "scope": f"{AUDIENCE}/.default", } - # Cached in the DB under the synthetic __app__ user — second call, no IdP. + # Cached in the DB under the synthetic __app__ user, identity-keyed + # on the owning alias — second call, no IdP. assert _mint_app(state) == "app-at" assert client.post.call_count == 1 - cache_server = f"__model_app__:{AUDIENCE}" + cache_server = f"__model_app__:{APP_ALIAS}" raw = storage.get_mcp_user_token("__app__", cache_server) assert raw is not None and raw["refresh_token_ct"] is None @@ -709,6 +1211,36 @@ class TestMintAppAccessToken: assert _mint_app(state, force_refresh=True) == "app-2" assert client.post.call_count == 2 + def test_control_characters_stripped_from_audience_and_alias( + self, storage: SQLiteBackend + ) -> None: + """Raw-caller hygiene, matching the OBO twin: control characters + strip from the audience before the wire request and the cache row, + and from the alias inside the key builder — so no control byte ever + reaches the IdP, the row columns, or the identity key. Controls + strip BEFORE the whitespace trim: the edge control below shields a + space that the trim must still remove afterwards. + """ + from turnstone.core.mcp_oauth import model_app_cache_server + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + return_value=_mk_response(200, {"access_token": "app-at", "expires_in": 3600}) + ) + state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config()) + + dirty_audience = chr(0x01) + " " + AUDIENCE[:8] + chr(0x1F) + AUDIENCE[8:] + dirty_alias = "gw-" + chr(0x01) + "app" + assert _mint_app(state, alias=dirty_alias, audience=dirty_audience) == "app-at" + # The wire request carries the stripped audience. + assert client.post.call_args.kwargs["data"]["scope"] == f"{AUDIENCE}/.default" + # The cache row lives under the stripped identity key with the + # stripped audience column. + raw = storage.get_mcp_user_token("__app__", model_app_cache_server("gw-app")) + assert raw is not None + plain = state.mcp_token_store.get_user_token("__app__", model_app_cache_server("gw-app")) + assert plain is not None and plain["audience"] == AUDIENCE + # --------------------------------------------------------------------------- # ChatSession._model_backend_auth_token — resolve at the model call site @@ -745,23 +1277,97 @@ class TestModelOboToken: provider: str = "anthropic", *, api_key: str = "static-fallback", + alias: str = "tf", + auth_mode: str = "entra_obo", + obo_audience: str = AUDIENCE, + obo_scopes: str = "", ) -> ModelConfig: return ModelConfig( - alias="tf", + alias=alias, base_url="https://gateway.example.com", api_key=api_key, model="vmg/opus", provider=provider, - auth_mode="entra_obo", - obo_audience=AUDIENCE, + auth_mode=auth_mode, + obo_audience=obo_audience, + obo_scopes=obo_scopes, ) def test_obo_alias_with_user_returns_token(self) -> None: reg = _registry_with(self._obo_cfg()) sess = _fake_session(registry=reg, user_id=USER, mint_token="minted-jwt") assert ChatSession._model_backend_auth_token(sess, "tf") == "minted-jwt" + # The mode pins its grant leg; entra_obo never forwards scopes. The + # owning alias rides along — the mint's cache and cause key. sess._mcp_mint_client.mint_model_obo_token_sync.assert_called_once_with( - user_id=USER, audience=AUDIENCE + user_id=USER, alias="tf", audience=AUDIENCE, scopes="", grant_leg="entra" + ) + + def test_rfc8693_alias_passes_scopes_and_leg(self) -> None: + cfg = self._obo_cfg(alias="tf-kc", auth_mode="rfc8693_obo", obo_scopes="aud-gw openid") + sess = _fake_session(registry=_registry_with(cfg), user_id=USER, mint_token="minted-jwt") + assert ChatSession._model_backend_auth_token(sess, "tf-kc") == "minted-jwt" + sess._mcp_mint_client.mint_model_obo_token_sync.assert_called_once_with( + user_id=USER, + alias="tf-kc", + audience=AUDIENCE, + scopes="aud-gw openid", + grant_leg="rfc8693", + ) + + def test_rfc8693_no_user_context_refuses_and_never_mints(self) -> None: + """The no-user guard derives from the app-identity complement, so the + new delegated mode inherits it rather than needing its own arm.""" + cfg = self._obo_cfg(alias="tf-kc", auth_mode="rfc8693_obo", obo_scopes="aud-gw") + sess = _fake_session(registry=_registry_with(cfg), user_id=None, mint_token="never") + with pytest.raises(BackendAuthUnavailableError): + ChatSession._model_backend_auth_token(sess, "tf-kc") + sess._mcp_mint_client.mint_model_obo_token_sync.assert_not_called() + + def test_rfc8693_fallback_warn_reads_scoped_cause( + self, storage: SQLiteBackend, caplog: pytest.LogCaptureFixture + ) -> None: + """The heartbeat keys its cause readback by the OWNING ALIAS, so an + alias's refusal names ITS cause rather than a sibling definition's + record (or unknown).""" + import logging + + client = MagicMock(spec=httpx.AsyncClient) + client.post = AsyncMock( + side_effect=[ + _mk_response(200, {"access_token": "subject-at", "expires_in": 300}), + _mk_response(400, {"error": "invalid_request"}), + ] + ) + state = _make_app_state( + storage, + http_client=client, + oidc_config=make_oidc_config(obo_grant_profile="rfc8693"), + ) + _seed_credential(state) + assert _mint(state, alias="tf-kc", scopes="aud-gw", grant_leg="rfc8693") is None + + cfg = self._obo_cfg(alias="tf-kc", auth_mode="rfc8693_obo", obo_scopes="aud-gw") + sess = _fake_session(registry=_registry_with(cfg), user_id=USER, mint_token=None) + with caplog.at_level(logging.WARNING): + assert ChatSession._model_backend_auth_token(sess, "tf-kc") is None + matching = [ + r + for r in caplog.records + if "model_obo.fallback_to_static" in r.getMessage() + str(r.__dict__) + ] + assert matching, caplog.records + blob = " ".join(r.getMessage() + str(r.__dict__) for r in matching) + assert "mint_failed" in blob + + def test_entra_obo_scopes_residue_stays_inert(self) -> None: + """Stored scopes on a mode outside SCOPES_MODEL_AUTH_MODES never reach + the mint — the dispatch, not the store, is what keeps residue inert.""" + cfg = self._obo_cfg(obo_scopes="stale-scope") + sess = _fake_session(registry=_registry_with(cfg), user_id=USER, mint_token="minted-jwt") + assert ChatSession._model_backend_auth_token(sess, "tf") == "minted-jwt" + sess._mcp_mint_client.mint_model_obo_token_sync.assert_called_once_with( + user_id=USER, alias="tf", audience=AUDIENCE, scopes="", grant_leg="entra" ) def test_fallback_warn_names_last_recorded_mint_cause( @@ -770,7 +1376,8 @@ class TestModelOboToken: """The per-turn fallback warn names the last recorded cause inline.""" import logging - # A refused mint records its cause (typo'd grant profile). + # A refused mint records its cause (typo'd grant profile), under the + # same entra leg the entra_obo dispatch below pins. client = MagicMock(spec=httpx.AsyncClient) client.post = AsyncMock() state = _make_app_state( @@ -779,7 +1386,7 @@ class TestModelOboToken: oidc_config=make_oidc_config(obo_grant_profile="bogus"), ) _seed_credential(state) - assert _mint(state) is None + assert _mint(state, alias="tf", grant_leg="entra") is None # The decision layer: the mint client yields nothing. reg = _registry_with(self._obo_cfg()) @@ -810,12 +1417,19 @@ class TestModelOboToken: _seed_credential(state) state.mcp_token_store = MCPTokenStore(storage, make_mcp_token_cipher(), node_id="B") - assert _mint(state) is None + # Aliased and legged like the entra_obo dispatch below, so the + # record lands on the key its heartbeat reads. + assert _mint(state, alias="tf", grant_leg="entra") is None assert client.post.call_count == 0 # refused before any IdP traffic - from turnstone.core.mcp_oauth import model_mint_refusal_cause + from turnstone.core.mcp_oauth import model_mint_refusal_cause, model_obo_cause_key - assert model_mint_refusal_cause("model_obo", AUDIENCE, USER) == "credential_decrypt_failure" + assert ( + model_mint_refusal_cause( + "model_obo", model_obo_cause_key("tf", grant_leg="entra"), USER + ) + == "credential_decrypt_failure" + ) # And the per-turn heartbeat renders it inline. reg = _registry_with(self._obo_cfg()) @@ -897,7 +1511,10 @@ class TestModelOboToken: assert intent_judge._backend_auth_resolver("tf") == "minted-jwt" session._mcp_mint_client.mint_model_obo_token_sync.assert_called_once_with( user_id=USER, + alias="tf", audience=AUDIENCE, + scopes="", + grant_leg="entra", ) session._mcp_mint_client.mint_app_token_sync.assert_not_called() @@ -1017,6 +1634,21 @@ class TestModelOboToken: sess = _fake_session(registry=reg, user_id=USER, mint_token="x") assert ChatSession._model_backend_auth_token(sess, "does-not-exist") is None + def test_unclassified_delegated_mode_fails_closed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A delegated mode with no registered grant-profile pairing cannot + pin a leg, so the dispatch refuses loudly before the mint bridge — + minting with leg=None would run the pre-dedicated-mode overload.""" + import turnstone.core.session as session_module + + monkeypatch.setattr(session_module, "MODEL_AUTH_MODE_PROFILES", {}) + reg = _registry_with(self._obo_cfg()) + sess = _fake_session(registry=reg, user_id=USER, mint_token="never") + with pytest.raises(BackendAuthUnavailableError, match="grant-profile pairing"): + ChatSession._model_backend_auth_token(sess, "tf") + sess._mcp_mint_client.mint_model_obo_token_sync.assert_not_called() + # -- entra_app (app-identity / client-credentials) -------------------------- def _app_cfg(self, *, api_key: str = "static-fallback") -> ModelConfig: @@ -1036,7 +1668,9 @@ class TestModelOboToken: reg = _registry_with(self._app_cfg()) sess = _fake_session(registry=reg, user_id="", mint_token=None, app_token="app-jwt") assert ChatSession._model_backend_auth_token(sess, "tf") == "app-jwt" - sess._mcp_mint_client.mint_app_token_sync.assert_called_once_with(audience=AUDIENCE) + sess._mcp_mint_client.mint_app_token_sync.assert_called_once_with( + alias="tf", audience=AUDIENCE + ) sess._mcp_mint_client.mint_model_obo_token_sync.assert_not_called() def test_app_alias_uses_app_identity_even_with_user(self) -> None: @@ -1058,3 +1692,47 @@ class TestModelOboToken: with pytest.raises(BackendAuthUnavailableError): ChatSession._model_backend_auth_token(sess, "tf") + + +class TestMintBridgeContractViolation: + def test_contract_error_returns_none_and_logs_error( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """The sync bridge demotes ordinary mint failures to debug, but a + MintDispatchContractError is a caller-contract violation (scopes + without the exchange leg pinned) and must surface at ERROR — while + still returning the fallback-eligible None.""" + import logging + import threading + + from turnstone.core import mcp_client as mcp_client_module + from turnstone.core.mcp_oauth import MintDispatchContractError + + async def _raiser(**_kwargs: Any) -> str | None: + raise MintDispatchContractError( + "mint_obo_access_token: scopes require grant_leg='rfc8693'" + ) + + monkeypatch.setattr(mcp_client_module, "mint_obo_access_token", _raiser) + + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + stub = SimpleNamespace(_loop=loop, _app_state=object()) + with caplog.at_level(logging.DEBUG): + token = mcp_client_module.MCPClientManager.mint_model_obo_token_sync( + stub, user_id=USER, alias=MODEL_ALIAS, audience=AUDIENCE, scopes="aud-gw" + ) + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + loop.close() + + assert token is None + errors = [ + r + for r in caplog.records + if r.levelno == logging.ERROR and "contract violation" in r.getMessage() + ] + assert errors, caplog.records diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py index b32aa603..79ed57d6 100644 --- a/tests/test_model_registry.py +++ b/tests/test_model_registry.py @@ -1175,6 +1175,153 @@ class TestReloadKeyGuard: assert reg.has_dynamic_auth() +class TestProfileMismatchVisibility: + """``profile_mismatched_aliases`` and its reload-chokepoint warning: a + persisted row whose mode names the other grant dialect stays loadable + but can never mint, and every swap must say so.""" + + @staticmethod + def _mixed_models() -> dict[str, ModelConfig]: + return { + "plain": ModelConfig("plain", "http://x/v1", "key", "m"), + "gw-entra": ModelConfig( + "gw-entra", + "http://gw/v1", + "", + "m", + auth_mode="entra_obo", + obo_audience="api://gw", + ), + "gw-app": ModelConfig( + "gw-app", + "http://gw/v1", + "", + "m", + auth_mode="entra_app", + obo_audience="api://gw", + ), + "gw-kc": ModelConfig( + "gw-kc", + "http://gw/v1", + "", + "m", + auth_mode="rfc8693_obo", + obo_audience="api://gw", + ), + } + + def test_helper_returns_mismatched_rows_sorted(self) -> None: + from turnstone.core.model_registry import profile_mismatched_aliases + + assert profile_mismatched_aliases(self._mixed_models(), "rfc8693") == [ + ("gw-app", "entra_app", "entra"), + ("gw-entra", "entra_obo", "entra"), + ] + assert profile_mismatched_aliases(self._mixed_models(), "entra") == [ + ("gw-kc", "rfc8693_obo", "rfc8693") + ] + + def test_helper_skips_static_and_unmapped_modes(self) -> None: + from turnstone.core.model_registry import profile_mismatched_aliases + + # Direct construction bypasses load-path validation, standing in for + # a future dynamic mode nobody has paired yet: not a PROFILE + # mismatch — the write validator and dispatch own that class. + models = { + "plain": ModelConfig("plain", "http://x/v1", "key", "m"), + "gw-next": ModelConfig( + "gw-next", + "http://gw/v1", + "", + "m", + auth_mode="future_mode", + obo_audience="api://gw", + ), + } + assert profile_mismatched_aliases(models, "rfc8693") == [] + + def test_reload_warns_per_mismatched_row(self, caplog: pytest.LogCaptureFixture) -> None: + import logging + + reg = ModelRegistry(models={"a": ModelConfig("a", "http://x/v1", "key", "m")}, default="a") + state = keyed_app_state() + state.oidc_config = SimpleNamespace(enabled=True, obo_grant_profile="rfc8693") + models = { + "gw-entra": ModelConfig( + "gw-entra", + "http://gw/v1", + "", + "m", + auth_mode="entra_obo", + obo_audience="api://gw", + ), + } + with caplog.at_level(logging.WARNING): + reg.reload(models, "gw-entra", app_state=state) + blob = " ".join(r.getMessage() for r in caplog.records) + assert "gw-entra" in blob + assert "grant_profile_mismatch" in blob + assert "'rfc8693'" in blob and "'entra'" in blob + + def test_no_mismatch_warning_when_oidc_disabled(self, caplog: pytest.LogCaptureFixture) -> None: + """OIDC-disabled deployments must NOT get the mismatch warning: the + loaded config defaults obo_grant_profile even when OIDC is off, and + the runtime refuses at the enabled check first — so the warning + would name a remedy (flip the profile) that cannot make the alias + mint, contradicting the heartbeat's oidc_not_enabled cause. + """ + import logging + + from turnstone.core.model_registry import warn_profile_mismatched_aliases + + models = { + "gw-kc": ModelConfig( + "gw-kc", + "http://gw/v1", + "", + "m", + auth_mode="rfc8693_obo", + obo_audience="api://gw", + ), + } + for oidc in ( + None, + SimpleNamespace(enabled=False, obo_grant_profile="entra"), + ): + caplog.clear() + with caplog.at_level(logging.WARNING): + warn_profile_mismatched_aliases(models, SimpleNamespace(oidc_config=oidc)) + assert not [r for r in caplog.records if "will not mint" in r.getMessage()] + + def test_mismatch_warning_names_the_mode_correct_cause( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """The warning's cause token must match what the alias's mint + actually records: the app-identity mint refuses a non-entra profile + as unsupported_grant_profile, the delegated legs as + grant_profile_mismatch — an operator greps the runtime heartbeat + for exactly the token the boot warning named. + """ + import logging + + from turnstone.core.model_registry import warn_profile_mismatched_aliases + + state = SimpleNamespace( + oidc_config=SimpleNamespace(enabled=True, obo_grant_profile="rfc8693") + ) + with caplog.at_level(logging.WARNING): + warn_profile_mismatched_aliases(self._mixed_models(), state) + by_alias = { + alias: r.getMessage() + for r in caplog.records + for alias in ("gw-app", "gw-entra") + if f"'{alias}'" in r.getMessage() + } + assert "unsupported_grant_profile" in by_alias["gw-app"] + assert "unsupported_grant_profile" not in by_alias["gw-entra"] + assert "grant_profile_mismatch" in by_alias["gw-entra"] + + # --------------------------------------------------------------------------- # Session integration # --------------------------------------------------------------------------- @@ -2767,3 +2914,93 @@ class TestApplyRoutingOverrides: cs = _FakeCS(**{"model.task_alias": "nonexistent"}) assert _apply_routing_overrides(reg, cs, _KEYED_STATE) is False assert reg.task_model is None # unchanged + + +# --------------------------------------------------------------------------- +# Auth-mode classification maps — drift guards +# --------------------------------------------------------------------------- + + +def test_model_auth_mode_profile_map_matches_mint_legs() -> None: + """The registry's pairing map and the mint-leg registry agree by test, + not by import: model_registry deliberately spells profile names as + literals to keep the mint stack off its import graph, so this is the + seam that catches a rename or an unclassified mode. + """ + from turnstone.core.mcp_oauth import OBO_GRANT_PROFILES + + # Every dynamic mode names its required profile — a mode missing here is + # never posture-approvable and never mints, which is fail-closed but + # must be a deliberate state, not an oversight. + assert set(mr_module.MODEL_AUTH_MODE_PROFILES) == set(mr_module.DYNAMIC_MODEL_AUTH_MODES) + # And every named profile has a real mint leg. + assert set(mr_module.MODEL_AUTH_MODE_PROFILES.values()) <= OBO_GRANT_PROFILES + + +def test_auth_mode_classification_sets_are_subsets_of_dynamic() -> None: + assert mr_module.SCOPES_MODEL_AUTH_MODES <= mr_module.DYNAMIC_MODEL_AUTH_MODES + assert mr_module.APP_IDENTITY_MODEL_AUTH_MODES <= mr_module.DYNAMIC_MODEL_AUTH_MODES + # The scopes-reading and app-identity classes are disjoint: an app mode + # that read user-facing exchange scopes would have no coherent principal. + assert not (mr_module.SCOPES_MODEL_AUTH_MODES & mr_module.APP_IDENTITY_MODEL_AUTH_MODES) + + +def test_obo_scopes_normalizers_agree_across_modules() -> None: + """The registry, console, and mint each own their scopes-normalization + POLICY (refuse-vs-strip on control garbage), but their SPELLING must + agree — or the console stores a value the mint keys its cache row under + differently than the session heartbeat's rebuild. All three now + delegate to ``sanitize_backend_auth_scopes``; this corpus pins the + delegation and the per-layer policies wrapped around it. + """ + from turnstone.core.mcp_oauth import _normalized_mint_scopes + + corpus = [ + "", + "aud-gw", + "aud-gw openid", + " aud-gw openid ", + "aud-gw\topenid", + "aud-gw\n openid", + "aud-gw openid", # already normalized — idempotence + ] + for raw in corpus: + registry_value = mr_module._normalize_auth_mode("gw", "rfc8693_obo", "api://gw", raw)[2] + shared = mr_module.sanitize_backend_auth_scopes(raw) + # The console's stored spelling IS the shared transform's output + # (its parser delegates), so pinning registry == mint == shared + # covers all three write/read surfaces. + assert registry_value == _normalized_mint_scopes(raw) == shared, raw + # Interior NON-whitespace controls are where the policies deliberately + # split: the registry refuses to LOAD what the write paths would have + # stripped before storing — and the stripping paths still agree with + # the shared transform. + dirty = "aud-gw" + chr(1) + "openid" + with pytest.raises(mr_module.ModelAuthConfigError): + mr_module._normalize_auth_mode("gw", "rfc8693_obo", "api://gw", dirty) + assert _normalized_mint_scopes(dirty) == "aud-gwopenid" + assert mr_module.sanitize_backend_auth_scopes(dirty) == "aud-gwopenid" + # The C0 separator block counts as Python whitespace, so a bare + # str.split() would swallow it before the guard could refuse; the + # registry must refuse it like every other control byte, while the + # sanctioned separators (tab/newline/CR, blessed in the corpus above) + # keep collapsing. + for sep_byte in (chr(0x1C), chr(0x1D), chr(0x1E), chr(0x1F)): + with pytest.raises(mr_module.ModelAuthConfigError): + mr_module._normalize_auth_mode( + "gw", "rfc8693_obo", "api://gw", f"aud-gw{sep_byte}openid" + ) + + +def test_control_bearing_alias_refuses_to_load() -> None: + """The alias is the identity every mint-cache, cooldown, cause and purge + key derives from, and the key builders strip control characters as a + raw-caller seam — so a control-bearing alias would silently collide with + its stripped twin, merging two definitions onto one identity. The load + refuses it like any other backend-auth text garbage, for every mode. + """ + for mode in ("static", "rfc8693_obo"): + with pytest.raises(mr_module.ModelAuthConfigError, match="alias contains control"): + mr_module._normalize_auth_mode( + "gw" + chr(1), mode, "api://gw" if mode != "static" else "", "" + ) diff --git a/tests/test_server_node_models_metadata.py b/tests/test_server_node_models_metadata.py index 3f49aa44..ee53ae99 100644 --- a/tests/test_server_node_models_metadata.py +++ b/tests/test_server_node_models_metadata.py @@ -23,6 +23,7 @@ from turnstone.core.model_registry import ModelConfig, ModelRegistry from turnstone.server import ( _collect_node_models_metadata, _publish_models_metadata, + internal_model_status, ) @@ -125,6 +126,29 @@ def test_alias_with_no_tracker_yet_defaults_to_healthy(): assert rows[0]["healthy"] is True +def test_model_status_route_carries_backend_auth_fields(): + """The node model-status payload serves the per-alias backend-auth + trio (mode, audience, scopes) — the console's model shelf and the + cluster status view read them from THIS route, so dropping a field + here silently blanks the admin surface.""" + cfg = ModelConfig( + alias="gw", + base_url="http://gw/v1", + api_key="", + model="m", + auth_mode="rfc8693_obo", + obo_audience="api://gw", + obo_scopes="aud-gw openid", + ) + reg = ModelRegistry(models={"gw": cfg}, default="gw") + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(registry=reg))) + body = json.loads(internal_model_status(request).body) + entry = body["models"]["gw"] + assert entry["auth_mode"] == "rfc8693_obo" + assert entry["obo_audience"] == "api://gw" + assert entry["obo_scopes"] == "aud-gw openid" + + # --------------------------------------------------------------------------- # _publish_models_metadata — cache short-circuit + projection wiring # --------------------------------------------------------------------------- diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index 4c996e39..25434db4 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -1016,10 +1016,12 @@ class ModelDefinitionInfo(BaseModel): reasoning_effort: str | None = None surface_persisted_reasoning: bool = True replay_reasoning_to_model: bool = False - # "static" (send api_key), "entra_obo" (delegated-user token), or - # "entra_app" (shared app token) for obo_audience at call time. + # "static" (send api_key), "entra_obo" / "rfc8693_obo" (delegated-user + # token), or "entra_app" (shared app token) for obo_audience at call + # time; obo_scopes is the rfc8693 exchange-leg scope request. auth_mode: str = "static" obo_audience: str = "" + obo_scopes: str = "" source: str = "" created_by: str = "" created: str = "" @@ -1064,6 +1066,7 @@ class CreateModelDefinitionRequest(BaseModel): replay_reasoning_to_model: bool = False auth_mode: str = "static" obo_audience: str = "" + obo_scopes: str = "" class UpdateModelDefinitionRequest(BaseModel): @@ -1092,6 +1095,7 @@ class UpdateModelDefinitionRequest(BaseModel): replay_reasoning_to_model: bool | None = None auth_mode: str | None = None obo_audience: str | None = None + obo_scopes: str | None = None class ListModelDefinitionsResponse(BaseModel): @@ -1125,9 +1129,10 @@ class ModelAuthConstraintsResponse(BaseModel): auth_grant_profile: str = Field( description=( "Deployment [oidc] obo_grant_profile, or empty when single sign-on " - "is not configured. entra_app requires 'entra'; entra_obo works " - "under either profile. A transient discovery outage reports the " - "configured profile, not empty." + "is not configured. Each dynamic auth_mode pairs with exactly one " + "profile (see auth_mode_profiles); the write validator refuses a " + "new pairing that contradicts it. A transient discovery outage " + "reports the configured profile, not empty." ), ) dynamic_auth_modes: list[str] = Field( @@ -1139,6 +1144,27 @@ class ModelAuthConstraintsResponse(BaseModel): "fallback only for a missing or failed constraints fetch." ), ) + scopes_auth_modes: list[str] = Field( + description=( + "auth_mode values whose mint reads obo_scopes (the token-exchange " + "scope request), same server-derived contract as " + "dynamic_auth_modes; drives the scopes input's visibility." + ), + ) + app_identity_auth_modes: list[str] = Field( + description=( + "auth_mode values that mint a shared app/deployment identity " + "rather than a per-user one, same server-derived contract as " + "dynamic_auth_modes; drives the model list's auth badge wording." + ), + ) + auth_mode_profiles: dict[str, str] = Field( + description=( + "Required [oidc] obo_grant_profile per dynamic auth_mode. " + "Affordance for greying options that cannot validate under this " + "deployment's profile; the write validator remains the authority." + ), + ) class PersonaInfo(BaseModel): diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 4f5059fc..95ffc052 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -62,9 +62,15 @@ from turnstone.core.mcp_crypto import STARTUP_KEY_REQUIRED_HINT, is_user_scoped_ from turnstone.core.memory import get_workstream_display_names from turnstone.core.metacognition import field_str, sanitize_display from turnstone.core.model_registry import ( + APP_IDENTITY_MODEL_AUTH_MODES, DYNAMIC_MODEL_AUTH_MODES, + MODEL_AUTH_MODE_PROFILES, + MODEL_AUTH_TEXT_MAX_LEN, + SCOPES_MODEL_AUTH_MODES, DynamicAuthKeyError, dynamic_auth_key_error, + sanitize_backend_auth_scopes, + strip_control_characters, ) from turnstone.core.model_registry import MODEL_AUTH_MODES as _MODEL_AUTH_MODES from turnstone.core.rendezvous import NoAvailableNodeError @@ -9685,33 +9691,92 @@ _MCP_MAX_SERVERS = 200 # fallback; prefer cluster.mcp_max_servers from storage _MCP_AUTH_TYPES = frozenset({"none", "static", "oauth_user", "oauth_obo"}) -# Cleaning bound for a model definition's ``obo_audience``. ONE constant: -# the derive-gate's stored-side normalization and the create/update twins' -# input cleaning must truncate identically, or a long stored audience -# cleans differently on the two sides of the pair-change compare and every -# full-form save on such a row reads as a pair change. -OBO_AUDIENCE_MAX_LEN = 2048 - - def _clean_oauth_text(value: Any, *, max_length: int = 512) -> str | None: """Normalize an admin form OAuth text field — empty string -> None. Caps the input to ``max_length`` characters to bound DB row size on the admin.mcp write path. Pass a larger ``max_length`` (e.g. 2048) - for URL fields where the default would otherwise truncate valid - long URLs; model ``obo_audience`` sites pass - :data:`OBO_AUDIENCE_MAX_LEN`. + for URL fields where the default would otherwise truncate valid long + URLs; model backend-auth sites (``obo_audience``/``obo_scopes``) pass + the REGISTRY's bound, :data:`MODEL_AUTH_TEXT_MAX_LEN`, so the console + never stores a length the registry load then refuses and the gate's + stored-side normalization truncates identically to the twins' input + cleaning. """ if value is None: return None # OAuth identifiers/URLs have no valid C0 controls. Removing them here - # prevents log/header ambiguity and benefits both MCP and model auth. - text = re.sub(r"[\x00-\x1f\x7f]", "", str(value)).strip() + # (via the one shared spelling of the control class) prevents log/header + # ambiguity and benefits both MCP and model auth. + text = strip_control_characters(str(value)).strip() if not text: return None return text[:max_length] +def _parse_obo_scopes_field(raw: Any, stored: Any = None) -> tuple[str | None, JSONResponse | None]: + """Parse a submitted ``obo_scopes`` field against the length policy. + + The ONE parser both write twins use. Returns ``(value, None)`` to store + ``value``, ``(None, error)`` for a refused over-length submission, or + ``(None, None)`` — over-length but IDENTICAL to *stored* under the same + uncapped transform — which the update twin maps to "omit the column so + the server preserves the stored value". That omit-unchanged arm is what + keeps a DB-direct over-length row disarmable and full-form-resavable: + the echo of the row's own residue is not a change, and refusing it would + wedge the row (in particular, the pure-disable carve-out must never be + blocked by residue the operator is not touching). + + The bound measures the CLEANED value: length policy bounds what is + STORED, and the stored form is the sanitized spelling — measuring the + raw paste would refuse input whose stored form fits (control bytes a + terminal paste smuggles in are stripped, never counted). ``stored=None`` + (the create twin) has no residue to echo, so over-length always refuses. + """ + full = sanitize_backend_auth_scopes(raw) + if len(full) <= MODEL_AUTH_TEXT_MAX_LEN: + return full, None + if stored is not None and full == sanitize_backend_auth_scopes(stored): + return None, None + return None, JSONResponse({"error": _SCOPES_TOO_LONG_ERROR}, status_code=400) + + +def _purge_model_mint_cache(storage: Any, definition_id: str, alias: str) -> None: + """Best-effort purge of a model definition's mint-cache rows. + + Deletes the definition's rows under BOTH synthetic prefixes — the + per-user OBO rows and the shared app-identity row — for the *alias* that + owned them. Sound because the keys are IDENTITY-keyed on the unique + alias, exactly as the MCP-server purges above key on the unique server + name: one owning definition per key, so a sibling definition's rows are + untouchable by construction. Purging a prefix the row's mode never + minted under is a no-op, so the helper needs no mode dispatch. The ONE + spelling both write twins call — update (rename / re-aim / scope + change) and delete — so the key build and the failure posture cannot + drift between them. Best-effort: the definition write is already + committed, and the mint-side freshness gate refuses a superseded row + regardless — this purge is at-rest hygiene, the gate is the serving + guarantee. + """ + from turnstone.core.mcp_oauth import model_app_cache_server, model_obo_cache_server + + if not alias: + return + # Per-prefix isolation: a failure deleting one prefix's rows must not + # abort the other's — the both-prefixes contract holds under partial + # storage failure, each miss logged on its own. + for server_key in (model_obo_cache_server(alias), model_app_cache_server(alias)): + try: + storage.delete_mcp_oauth_rows_by_server_name(server_key) + except Exception: + log.warning( + "admin.models.purge_mint_cache_failed definition_id=%s server=%s", + definition_id, + server_key, + exc_info=True, + ) + + def _parse_auth_type(body: dict[str, Any]) -> tuple[str | None, JSONResponse | None]: """Validate ``auth_type`` from a request body. @@ -11535,6 +11600,16 @@ _AUDIENCE_FORBIDS_STATIC_ERROR = ( f"({'/'.join(sorted(DYNAMIC_MODEL_AUTH_MODES))}); " "omit it when auth_mode is 'static'" ) +# The scopes staging guard's refusal, shared by both twins like its audience +# siblings above. Mode list derived, same rationale. +_SCOPES_REQUIRE_EXCHANGE_MODE_ERROR = ( + "obo_scopes is only used by auth_mode " + f"({'/'.join(sorted(SCOPES_MODEL_AUTH_MODES))}); " + "omit it for other modes" +) +# Over-length scopes are refused, not truncated: a silently shortened scope +# list changes what the exchange leg requests. Shared by both twins. +_SCOPES_TOO_LONG_ERROR = f"obo_scopes exceeds {MODEL_AUTH_TEXT_MAX_LEN} characters" def _canonical_capabilities(raw: Any) -> str | None: @@ -11586,12 +11661,20 @@ class ModelAuthGateDecision: - ``enabled_armed`` implies ``not pure_disable`` — arming and disarming are directional opposites. - ``posture_event`` is exactly ``pair_changed or enabled_armed``. + + ``scopes_value_changed`` is the gate's scopes comparator — exposed + because it is also a mint-cache purge trigger (a scope change re-shapes + the bearer the alias's rows hold), and the handler must fire the purge + on exactly the comparison the gate made, never a re-derivation that + could drift. """ eff_auth_mode: str eff_audience: str audience_required_violation: bool static_new_audience_violation: bool + scopes_staging_violation: bool + scopes_value_changed: bool pair_changed: bool dynamic_involved: bool enabled_armed: bool @@ -11645,7 +11728,7 @@ def _derive_auth_gate(existing: dict[str, Any], updates: dict[str, Any]) -> Mode """ old_auth_mode = str(existing.get("auth_mode") or "static") old_audience = ( - _clean_oauth_text(existing.get("obo_audience"), max_length=OBO_AUDIENCE_MAX_LEN) or "" + _clean_oauth_text(existing.get("obo_audience"), max_length=MODEL_AUTH_TEXT_MAX_LEN) or "" ) eff_auth_mode = str(updates.get("auth_mode", old_auth_mode)) eff_audience = str(updates.get("obo_audience", old_audience)) @@ -11660,6 +11743,34 @@ def _derive_auth_gate(existing: dict[str, Any], updates: dict[str, Any]) -> Mode and bool(updates["obo_audience"]) and str(updates["obo_audience"]) != old_audience ) + # The scopes twin of the staging guard: a mode that never reads scopes + # must not store a NEW value for a later mode flip to inherit. VALUE + # CHANGE only, so residue re-saves keep working — and a pure disable can + # never trip this (an unchanged or omitted scopes field is no violation, + # and a changed one already forecloses the carve-out via the gated-change + # loop below; pinned: + # test_pure_disable_with_stored_scopes_stays_carved_out). + # The stored-side baseline is the UNCAPPED shared sanitize: a DB-direct + # raw stored value must compare equal to its own collapsed re-save (the + # update ladder normalizes the incoming side), or every full-form + # submit — including the disarm — misreads residue as a change. The cap + # deliberately does NOT apply here: a capped baseline would let the + # capped SPELLING of over-cap residue compare as "no change", so an + # admin.models-only caller could rewrite a registry-refused value into + # a loadable, mintable one under the escalation gate's radar. Over-cap + # residue can never equal a storable submission, so any write to it is + # auth-gated; the wedge protection for untouched residue lives in the + # parser's omit-unchanged arm, not in this compare. + old_scopes = sanitize_backend_auth_scopes(existing.get("obo_scopes")) + # THE scopes comparator — the staging guard and the gated-change loop + # below both consume it, so the two predicates cannot drift on what + # counts as a value change. + scopes_value_changed = "obo_scopes" in updates and str(updates["obo_scopes"]) != old_scopes + scopes_staging_violation = ( + eff_auth_mode not in SCOPES_MODEL_AUTH_MODES + and scopes_value_changed + and bool(updates["obo_scopes"]) + ) # One derivation, two consumers: the outer gate and the validator's # posture tier. pair_changed = eff_auth_mode != old_auth_mode or eff_audience != old_audience @@ -11675,11 +11786,19 @@ def _derive_auth_gate(existing: dict[str, Any], updates: dict[str, Any]) -> Mode caps_changed = "capabilities" in updates and _capabilities_value_changed( existing.get("capabilities"), updates["capabilities"] ) - non_caps_gated_changed = any( - key not in ("enabled", "capabilities", "auth_mode", "obo_audience") - and key not in MODEL_AUTH_NEUTRAL_FIELDS - and str(existing.get(key) or "") != str(value or "") - for key, value in updates.items() + # obo_scopes joins the pair and capabilities in the loop's exclusion + # list: each excluded column has a dedicated normalization-correct + # comparator (scopes_value_changed above), and the raw-string loop + # would misread a DB-direct stored value's collapsed re-save as a + # change. + non_caps_gated_changed = ( + any( + key not in ("enabled", "capabilities", "auth_mode", "obo_audience", "obo_scopes") + and key not in MODEL_AUTH_NEUTRAL_FIELDS + and str(existing.get(key) or "") != str(value or "") + for key, value in updates.items() + ) + or scopes_value_changed ) other_gated_changed = non_caps_gated_changed or caps_changed caps_blocks_disarm = ( @@ -11706,6 +11825,8 @@ def _derive_auth_gate(existing: dict[str, Any], updates: dict[str, Any]) -> Mode eff_audience=eff_audience, audience_required_violation=audience_required_violation, static_new_audience_violation=static_new_audience_violation, + scopes_staging_violation=scopes_staging_violation, + scopes_value_changed=scopes_value_changed, pair_changed=pair_changed, dynamic_involved=dynamic_involved, enabled_armed=enabled_armed, @@ -11721,6 +11842,7 @@ def _validate_dynamic_model_auth( auth_mode: str, audience: str, posture_event: bool = True, + pair_changed: bool = True, ) -> JSONResponse | None: """Validate a dynamic model-auth config at the write choke point. @@ -11738,13 +11860,19 @@ def _validate_dynamic_model_auth( request CHOOSES the ``(auth_mode, obo_audience)`` pair rather than inheriting it, or RE-ARMS a disabled dynamic row (pinned: test_keyless_reenable_of_dynamic_row_returns_503). Checks, in sibling - order: token store present, OIDC configured, grant profile valid and - able to carry the mode. A same-pair edit is never posture-blocked — an - existing row must not be held hostage to posture that changed after it - was saved; the mint warns at runtime instead, exactly as the MCP - contract documents (pinned: + order: token store present, OIDC configured, grant profile valid. A + same-pair edit is never posture-blocked — an existing row must not be + held hostage to posture that changed after it was saved; the mint warns + at runtime instead, exactly as the MCP contract documents (pinned: test_base_url_edit_allowed_despite_typod_profile). + The mode/profile PAIRING is narrower still — a pair-CHOOSE rule, gated + on ``pair_changed``: re-arming an untouched pair keeps the row's + standing, whatever profile the deployment now runs (its mint refuses at + runtime with ``grant_profile_mismatch``, fallback-eligible by ruling), + while any request that picks the pair must pick one this deployment can + mint. + Every refusal names its actual cause and echoes what the operator configured. A missing token store is 503 (deployment fault, remedy-and-retry), matching the MCP sibling; config choices are 400. @@ -11823,17 +11951,53 @@ def _validate_dynamic_model_auth( }, status_code=400, ) - if auth_mode == "entra_app" and profile != "entra": - return JSONResponse( - { - "error": ( - f"auth_mode 'entra_app' requires [oidc] obo_grant_profile='entra' " - f"(configured: {profile!r}); RFC 8693 client-credentials is not " - "supported" + if pair_changed: + # Type-pairing: every dynamic mode names its grant dialect, so + # "mode matches deployment profile" is one derived rule instead of a + # per-mode special case. Only a pair CHOICE reaches this, so legacy + # rows persisted under the pre-pairing overload keep accepting + # same-pair edits and re-arms (pinned: + # test_base_url_edit_allowed_on_legacy_entra_obo_rfc8693_row, + # test_legacy_cross_profile_row_reenables_unchanged). + required = MODEL_AUTH_MODE_PROFILES.get(auth_mode) + if required is None: + # Fail-closed IN code, not by map absence: a dynamic mode nobody + # paired must be refused here with its remedy named — the + # registry drift test stays as the belt. + return JSONResponse( + { + "error": ( + f"auth_mode {auth_mode!r} has no registered grant-profile " + "pairing; add it to MODEL_AUTH_MODE_PROFILES before use" + ) + }, + status_code=400, + ) + elif profile != required: + if auth_mode in APP_IDENTITY_MODEL_AUTH_MODES: + remedy = "; RFC 8693 client-credentials is not supported" + else: + alternates = "/".join( + sorted( + mode + for mode, mode_profile in MODEL_AUTH_MODE_PROFILES.items() + if mode_profile == profile and mode not in APP_IDENTITY_MODEL_AUTH_MODES + ) ) - }, - status_code=400, - ) + remedy = ( + f"; delegated tokens under this profile use auth_mode {alternates!r}" + if alternates + else "" + ) + return JSONResponse( + { + "error": ( + f"auth_mode {auth_mode!r} requires [oidc] obo_grant_profile=" + f"{required!r} (configured: {profile!r}){remedy}" + ) + }, + status_code=400, + ) return None @@ -12171,6 +12335,7 @@ async def admin_list_model_definitions(request: Request) -> JSONResponse: "reasoning_effort": nm.get("reasoning_effort"), "auth_mode": nm.get("auth_mode", "static"), "obo_audience": nm.get("obo_audience", ""), + "obo_scopes": nm.get("obo_scopes", ""), "source": "config", "created_by": "", "created": "", @@ -12243,6 +12408,14 @@ async def admin_model_auth_constraints(request: Request) -> JSONResponse: # mirror across the language seam; the client's hand-list is only # the fail-open fallback for a missing/failed fetch. "dynamic_auth_modes": sorted(DYNAMIC_MODEL_AUTH_MODES), + # Same contract for the scopes input's visibility and the mode + # options' profile pairing (which options grey out for THIS + # deployment's grant profile). + "scopes_auth_modes": sorted(SCOPES_MODEL_AUTH_MODES), + # And for the model list's auth badge: app-identity modes render + # as a deployment identity, every other dynamic mode as per-user. + "app_identity_auth_modes": sorted(APP_IDENTITY_MODEL_AUTH_MODES), + "auth_mode_profiles": dict(sorted(MODEL_AUTH_MODE_PROFILES.items())), } ) @@ -12356,7 +12529,7 @@ async def admin_create_model_definition(request: Request) -> JSONResponse: if auth_mode not in _MODEL_AUTH_MODES: return JSONResponse({"error": f"Invalid auth_mode: {auth_mode!r}"}, status_code=400) obo_audience = ( - _clean_oauth_text(body.get("obo_audience"), max_length=OBO_AUDIENCE_MAX_LEN) or "" + _clean_oauth_text(body.get("obo_audience"), max_length=MODEL_AUTH_TEXT_MAX_LEN) or "" ) if auth_mode in DYNAMIC_MODEL_AUTH_MODES and not obo_audience: return JSONResponse({"error": _AUDIENCE_REQUIRED_ERROR}, status_code=400) @@ -12367,6 +12540,18 @@ async def admin_create_model_definition(request: Request) -> JSONResponse: # permission gate — it discriminates only on the request's own fields. if auth_mode == "static" and obo_audience: return JSONResponse({"error": _AUDIENCE_FORBIDS_STATIC_ERROR}, status_code=400) + # Scopes twin of the staging guard, same request-shape rationale: only a + # scope-reading mode may store scopes. Over-length input is REFUSED + # (audience keeps its truncate posture — the allow-list membership check + # backstops whatever a truncation produces; scopes have no such list). + # No stored row exists yet, so the parser's omit-unchanged arm never + # applies here: over-length always refuses. + obo_scopes_value, scopes_err = _parse_obo_scopes_field(body.get("obo_scopes")) + if scopes_err is not None: + return scopes_err + obo_scopes = obo_scopes_value or "" + if obo_scopes and auth_mode not in SCOPES_MODEL_AUTH_MODES: + return JSONResponse({"error": _SCOPES_REQUIRE_EXCHANGE_MODE_ERROR}, status_code=400) if auth_mode != "static": # Redeeming an operator-chosen audience is the same capability as # configuring oauth_audience on MCP; service credentials do not bypass @@ -12402,6 +12587,7 @@ async def admin_create_model_definition(request: Request) -> JSONResponse: replay_reasoning_to_model=replay_reasoning_to_model, auth_mode=auth_mode, obo_audience=obo_audience, + obo_scopes=obo_scopes, ) # Record the auth pair, as the update path already does: it is a @@ -12411,6 +12597,8 @@ async def admin_create_model_definition(request: Request) -> JSONResponse: if auth_mode != "static": audit_detail["auth_mode"] = auth_mode audit_detail["obo_audience"] = obo_audience + if obo_scopes: + audit_detail["obo_scopes"] = obo_scopes record_audit( storage, audit_uid, @@ -12605,8 +12793,23 @@ async def admin_update_model_definition(request: Request) -> JSONResponse: updates["auth_mode"] = am if "obo_audience" in body: updates["obo_audience"] = ( - _clean_oauth_text(body["obo_audience"], max_length=OBO_AUDIENCE_MAX_LEN) or "" + _clean_oauth_text(body["obo_audience"], max_length=MODEL_AUTH_TEXT_MAX_LEN) or "" ) + if "obo_scopes" in body: + # Same refusal as the create twin — over-length scope lists never + # truncate into the store (see the audience-asymmetry note there) — + # via the shared parser, whose omit-unchanged arm drops the key when + # the submission merely echoes over-length DB-direct residue: the + # gate below then sees no scopes change and the stored value + # survives verbatim, so the residue row stays disarmable and + # full-form-resavable. + scopes_value, scopes_err = _parse_obo_scopes_field( + body["obo_scopes"], stored=existing.get("obo_scopes") + ) + if scopes_err is not None: + return scopes_err + if scopes_value is not None: + updates["obo_scopes"] = scopes_value # Every gate fact derives purely in _derive_auth_gate, whose docstring # carries the rulings; this handler only maps fields to responses. gate = _derive_auth_gate(existing, updates) @@ -12617,6 +12820,8 @@ async def admin_update_model_definition(request: Request) -> JSONResponse: return JSONResponse({"error": _AUDIENCE_REQUIRED_ERROR}, status_code=400) if gate.static_new_audience_violation: return JSONResponse({"error": _AUDIENCE_FORBIDS_STATIC_ERROR}, status_code=400) + if gate.scopes_staging_violation: + return JSONResponse({"error": _SCOPES_REQUIRE_EXCHANGE_MODE_ERROR}, status_code=400) if gate.auth_config_changed: # Permission first: the validation 400s below describe deployment OIDC # posture and allow-list membership, which a caller lacking this scope @@ -12632,12 +12837,23 @@ async def admin_update_model_definition(request: Request) -> JSONResponse: auth_mode=gate.eff_auth_mode, audience=gate.eff_audience, posture_event=gate.posture_event, + pair_changed=gate.pair_changed, ) if dynamic_auth_error is not None: return dynamic_auth_error if updates: storage.update_model_definition(definition_id, **updates) + old_alias = str(existing.get("alias") or "") + alias_changed = "alias" in updates and updates["alias"] != old_alias + if alias_changed or gate.pair_changed or gate.scopes_value_changed: + # A rename orphans the OLD alias's identity keys outright; a + # re-aim or scope change leaves rows whose bearer was minted for + # the superseded shape. Either way the rows purge under the old + # alias — the mint-side freshness gate refuses stale rows + # regardless, so this is at-rest hygiene, not the serving + # guarantee. + _purge_model_mint_cache(storage, definition_id, old_alias) audit_uid, ip = _audit_context(request) audit_detail = dict(updates) @@ -12704,6 +12920,12 @@ async def admin_delete_model_definition(request: Request) -> JSONResponse: if existing is None: return JSONResponse({"error": "Model definition not found"}, status_code=404) + # Purge the definition's mint-cache rows before the row goes away — + # after the delete, nothing owns the alias's identity keys and their + # encrypted bearers would persist at rest until another definition + # claimed the alias. + _purge_model_mint_cache(storage, definition_id, str(existing.get("alias") or "")) + storage.delete_model_definition(definition_id) audit_uid, ip = _audit_context(request) diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 1c5b6769..3193c550 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -6417,7 +6417,7 @@ let _modelAuthFetchGen = 0; // The (mode, audience) as persisted on the row being edited — empty for a // create. Drives the hide/enable/hint state only; the server alone validates // submits (a stale client copy must never block a server-valid save). -let _modelAuthPersisted = { mode: "static", audience: "" }; +let _modelAuthPersisted = { mode: "static", audience: "", scopes: "" }; // Reranker calibration fields extracted out of the capabilities textarea in the // edit modal (like server_compat), held here so they survive an unrelated edit // and are re-merged on save. Reset per modal open. @@ -6813,20 +6813,50 @@ function _consoleWhenPermissionsReady(cb) { } } -// The dynamic (non-shared-key) auth-mode predicate. The authoritative answer -// is the FETCHED constraints' `dynamic_auth_modes` (server-derived from -// DYNAMIC_MODEL_AUTH_MODES in turnstone/core/model_registry.py), so the -// shelf's affordances track the server's classification by data. The -// hand-list below is the FAIL-OPEN FALLBACK ONLY: constraints not yet -// fetched, fetch failed, or a server that does not send the field. -function _isDynamicAuthMode(mode) { +// The served-data-first contract shared by the mode predicates: the FETCHED +// constraints array under `constraintsKey` is authoritative when present +// and well-formed (server-derived from the classification frozensets in +// turnstone/core/model_registry.py), so the shelf's affordances track the +// server's classification by data. `fallbackModes` is the hand-kept +// FAIL-OPEN FALLBACK ONLY: constraints not yet fetched, fetch failed, or a +// server that does not send the field. +function _servedModeListHas(constraintsKey, fallbackModes, mode) { if ( _modelAuthConstraints && - Array.isArray(_modelAuthConstraints.dynamic_auth_modes) + Array.isArray(_modelAuthConstraints[constraintsKey]) ) { - return _modelAuthConstraints.dynamic_auth_modes.indexOf(mode) !== -1; + return _modelAuthConstraints[constraintsKey].indexOf(mode) !== -1; } - return mode === "entra_obo" || mode === "entra_app"; + return fallbackModes.indexOf(mode) !== -1; +} + +// The hand-kept auth-mode -> grant-profile pairing, used ONLY as the +// fail-open fallback when served constraints are missing; the dynamic-mode +// fallback list derives from its keys so the two cannot drift. +const _AUTH_MODE_FALLBACK_PROFILES = { + entra_obo: "entra", + entra_app: "entra", + rfc8693_obo: "rfc8693", +}; + +// The dynamic (non-shared-key) auth-mode predicate. +function _isDynamicAuthMode(mode) { + return _servedModeListHas( + "dynamic_auth_modes", + Object.keys(_AUTH_MODE_FALLBACK_PROFILES), + mode, + ); +} + +// The scopes-reading mode predicate. +function _isScopesAuthMode(mode) { + return _servedModeListHas("scopes_auth_modes", ["rfc8693_obo"], mode); +} + +// The app-identity (shared deployment credential) mode predicate — drives +// the model list's auth badge wording; per-user is every OTHER dynamic mode. +function _isAppIdentityAuthMode(mode) { + return _servedModeListHas("app_identity_auth_modes", ["entra_app"], mode); } function _modelRolesAccessible() { @@ -6978,28 +7008,54 @@ function _syncModelAuthFields() { const section = document.getElementById("model-auth-section"); if (section) { const nothingDynamic = - !persistedDynamicMode && !dynamic && !_modelAuthPersisted.audience; + !persistedDynamicMode && + !dynamic && + !_modelAuthPersisted.audience && + !_modelAuthPersisted.scopes; const useless = (known && !profile && nothingDynamic) || (!editable && nothingDynamic); section.style.display = useless ? "none" : ""; } - // entra_app is client-credentials only — no RFC 8693 leg exists — so the - // option greys out when the profile is AFFIRMATIVELY known to be something - // else, but never for the mode the row is persisted with, or the select - // would fall back and rewrite the row on save. Unknown constraints disable - // nothing: affordance, not gate. Option labels live in index.html. - const appOpt = modeSel.querySelector('option[value="entra_app"]'); + // Each dynamic mode pairs with exactly one grant profile (served as + // auth_mode_profiles), so an option greys out when the profile is + // AFFIRMATIVELY known to be a different one — but never for the mode the + // row is persisted with, or the select would fall back and rewrite the + // row on save. Unknown constraints disable nothing: affordance, not gate. + // Option labels live in index.html; the hand-kept map is only the + // fallback for a server that predates auth_mode_profiles. + const modeProfiles = + known && _isPlainObject(_modelAuthConstraints.auth_mode_profiles) + ? _modelAuthConstraints.auth_mode_profiles + : _AUTH_MODE_FALLBACK_PROFILES; + // Own-property lookups only: option values and the persisted mode are + // arbitrary server-supplied strings, and a name like "toString" must + // read as unmapped rather than pull a function off Object.prototype. + const profileOf = function (key) { + return Object.prototype.hasOwnProperty.call(modeProfiles, key) + ? modeProfiles[key] + : undefined; + }; let unavailable = false; - if (appOpt) { - appOpt.disabled = + for (let i = 0; i < modeSel.options.length; i++) { + const opt = modeSel.options[i]; + const required = profileOf(opt.value); + if (!required) continue; // static and injected server-defined modes + opt.disabled = known && !!profile && - profile !== "entra" && - _modelAuthPersisted.mode !== "entra_app"; - unavailable = appOpt.disabled; + profile !== required && + _modelAuthPersisted.mode !== opt.value; + if (opt.disabled) unavailable = true; } + // A row PERSISTED on a mode this deployment's profile cannot mint (its + // option stays selectable above so the row round-trips) deserves the + // loudest hint: the save works but the credential never will. + const persistedRequired = profileOf(_modelAuthPersisted.mode); + const persistedMismatch = + known && !!profile && !!persistedRequired && persistedRequired !== profile; + modeSel.disabled = !editable; const stored = audSel.value || ""; @@ -7011,9 +7067,11 @@ function _syncModelAuthFields() { if (modeHint) { modeHint.textContent = !editable ? "needs the MCP admin permission to change" - : unavailable - ? "app identity needs the deployment to sign in through Entra" - : ""; + : persistedMismatch + ? "saved mode doesn't match this deployment's sign-in profile — it will not mint until changed" + : unavailable + ? "greyed-out modes need a different sign-in profile than this deployment uses" + : ""; } if (audHint) { // The field is free text, so missing constraints cost suggestions, not @@ -7038,6 +7096,31 @@ function _syncModelAuthFields() { } } + // Scopes input: the audience's affordance rules exactly — editable when + // the selected mode reads it, or when residue lingers so it can be + // cleared. Null-guarded so a stale cached page without the input keeps + // repainting the rest of the block. + const scopesInput = document.getElementById("model-obo-scopes"); + const scopesHint = document.getElementById("model-obo-scopes-hint"); + if (scopesInput) { + const scopesMode = _isScopesAuthMode(mode); + const storedScopes = scopesInput.value || ""; + scopesInput.disabled = !editable || (!scopesMode && !storedScopes); + if (scopesHint) { + if (!editable) { + scopesHint.textContent = "needs the MCP admin permission to change"; + } else if (!scopesMode) { + // Mirrors the server's staging guard, like the audience hint above. + scopesHint.textContent = storedScopes + ? "unused by this mode — clear it to drop the value; a different value would be refused" + : "only used by token-exchange modes"; + } else { + scopesHint.textContent = + "space-separated; requested on the token exchange (optional)"; + } + } + } + // The server treats a base-URL edit as an auth change when EITHER the // stored or the newly selected mode is dynamic, so mirror that // disjunction — keying only off the current selection would stay silent @@ -7441,8 +7524,14 @@ function _renderModels(items) { if (m.replay_reasoning_to_model === true) overrides.push("replay=on"); // Anything but the shared API key is worth showing: it changes whose // identity the gateway sees. Static is the default and stays silent. - if (m.auth_mode === "entra_obo") overrides.push("auth=per-user"); - else if (m.auth_mode === "entra_app") overrides.push("auth=deployment"); + // Derived from the shared mode predicates, never a hand list — a new + // dynamic mode gets a badge without touching this site. + if (_isDynamicAuthMode(m.auth_mode)) + overrides.push( + _isAppIdentityAuthMode(m.auth_mode) + ? "auth=deployment" + : "auth=per-user", + ); if (overrides.length) { const ovrSpan = document.createElement("span"); ovrSpan.className = "model-overrides-hint"; @@ -7653,9 +7742,11 @@ function showCreateModelModal() { _clearInjectedAuthModeOptions(document.getElementById("model-auth-mode")); document.getElementById("model-auth-mode").value = "static"; document.getElementById("model-obo-audience").value = ""; + const scopesReset = document.getElementById("model-obo-scopes"); + if (scopesReset) scopesReset.value = ""; // A create has no persisted row; the constraints fetch (fresh per open) // supplies the suggestions and re-syncs the block when it lands. - _modelAuthPersisted = { mode: "static", audience: "" }; + _modelAuthPersisted = { mode: "static", audience: "", scopes: "" }; _fetchModelAuthConstraints(); document.getElementById("model-detect-result").hidden = true; document.getElementById("model-detect-btn").disabled = false; @@ -7743,6 +7834,7 @@ function showEditModelModal(definitionId) { _modelAuthPersisted = { mode: m.auth_mode || "static", audience: m.obo_audience || "", + scopes: m.obo_scopes || "", }; const authModeSel = document.getElementById("model-auth-mode"); // An unknown persisted mode gets its own (marked) option so the row @@ -7753,6 +7845,8 @@ function showEditModelModal(definitionId) { // there regardless of what the suggestions contain. document.getElementById("model-obo-audience").value = m.obo_audience || ""; + const scopesEl = document.getElementById("model-obo-scopes"); + if (scopesEl) scopesEl.value = m.obo_scopes || ""; // Repaint against the row's values. NOT a second constraints fetch: // showCreateModelModal's reset already started one this shelf open and // constraints are row-independent. @@ -8053,16 +8147,19 @@ function submitCreateModel() { "model-replay-reasoning", ).checked; - // Backend auth: entra_obo mints a per-user OBO token for obo_audience at - // call time; entra_app mints an app-identity (client-credentials) token from - // Turnstone's SSO app reg. (The server re-validates the same pairing.) - // The || "static" default covers only a blank CREATE form: an edit-load - // injects an option for any server-defined mode (see + // Backend auth: entra_obo / rfc8693_obo mint a per-user OBO token for + // obo_audience at call time (the latter also requests obo_scopes on the + // exchange); entra_app mints an app-identity (client-credentials) token + // from Turnstone's SSO app reg. (The server re-validates the same + // pairing.) The || "static" default covers only a blank CREATE form: an + // edit-load injects an option for any server-defined mode (see // _ensureAuthModeOption), so edits always carry a real value. const authMode = document.getElementById("model-auth-mode").value || "static"; const oboAudience = document .getElementById("model-obo-audience") .value.trim(); + const scopesEl = document.getElementById("model-obo-scopes"); + const oboScopes = scopesEl ? scopesEl.value.trim() : ""; const authDynamic = _isDynamicAuthMode(authMode); if (authDynamic && oboAudience === "") { _showModelError("Enter a gateway audience for this auth mode"); @@ -8071,7 +8168,7 @@ function submitCreateModel() { const editId = document.getElementById("model-edit-id").value; Object.assign( form, - _authSubmitFields(authMode, oboAudience, !!editId, authDynamic), + _authSubmitFields(authMode, oboAudience, oboScopes, !!editId, !!scopesEl), ); const apiKey = document.getElementById("model-api-key").value; @@ -8113,16 +8210,31 @@ function submitCreateModel() { }); } -// Auth fields for a shelf submit, pure. A static CREATE OMITS the audience -// key entirely: the server refuses ANY non-empty audience there (no stored +// Auth fields for a shelf submit. A static CREATE OMITS the audience key +// entirely: the server refuses ANY non-empty audience there (no stored // row's value needs preserving), so sending leftovers a mode round-trip -// parked in the input would manufacture an avoidable 400. EDIT always sends -// the pair — stored-residue semantics are the server's call. -function _authSubmitFields(authMode, oboAudience, isEdit, authDynamic) { +// parked in the input would manufacture an avoidable 400. Scopes get the +// same treatment on a CREATE whose mode never reads them — and ride ONLY +// when the page actually renders the scopes input: on a cached pre-scopes +// index.html the read-back is a hardcoded "", and sending that on an EDIT +// would silently wipe a stored value the operator never saw (absent key = +// server preserves). The mode-derived facts are computed here, not +// parameters — three adjacent booleans made call sites transposable with +// no signal. +function _authSubmitFields( + authMode, + oboAudience, + oboScopes, + isEdit, + hasScopesInput, +) { const fields = { auth_mode: authMode }; - if (isEdit || authDynamic) { + if (isEdit || _isDynamicAuthMode(authMode)) { fields.obo_audience = oboAudience; } + if (hasScopesInput && (isEdit || _isScopesAuthMode(authMode))) { + fields.obo_scopes = oboScopes; + } return fields; } diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index ec8f9ecc..0430e1ed 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -1716,6 +1716,9 @@ +
@@ -1739,6 +1742,19 @@
+
+
+ + +
+