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 @@ +