feat(models): rfc8693_obo auth mode, per-alias exchange scopes, identity-keyed mint cache

Adds the dedicated `rfc8693_obo` model auth mode (#955): model
definitions gain an `obo_scopes` column (migration 069), the mint
threads the scopes to the token-exchange leg (RFC 8693), and every
dynamic mode pins its grant leg — a mode is a dialect commitment, not a
hint the deployment profile resolves. Exchange-capable IdPs refuse an
audience whose scope was not requested; this closes the structurally
unmintable model-OBO path on token-exchange deployments.

The model mint-cache is identity-keyed on the owning definition's
alias (`__model_obo__:<alias>` per user, `__model_app__:<alias>` under
the shared app principal), matching the MCP discipline where rows key
on the unique server name. The bearer's shape lives in the row's
audience/scopes columns and the freshness gate compares it on every
read, so a re-aimed alias refuses its old row and overwrites the same
key in place. Admin lifecycle (rename, re-aim, scope change, delete)
purges a definition's own rows through one shared helper — sound
because one definition owns each key; a sibling's rows are untouchable
by construction. Cooldown and backoff additionally key on the dispatch
shape, so an operator's config repair is an instant clean slate. Cause
records, cooldowns, locks and memoization are per-alias end to end,
and the session heartbeat reads refusal causes under the same keys.

Console: default-deny write gating for dynamic rows (value-diff over
the full column ladder, admin.mcp escalation, a never-blockable
pure-disable carve-out), a two-tier validator (audience allow-list on
every write; deployment-posture checks when the pair is chosen), one
shared scopes parser whose omit-unchanged arm keeps over-cap DB-direct
residue rows disarmable without ungating real changes, and served
constraints (dynamic/scopes/app-identity mode lists, mode-to-profile
pairing) so the shelf tracks the registry by data. The admin shelf
gains the mode option, a scopes input with residue affordances,
pairing-aware option greying, and a derived auth badge.

Registry load refuses control characters in alias, audience, and
scopes — including the C0 separator block that str.split() would
silently collapse — and the C0/DEL class has one exported spelling
shared by every surface. Profile-mismatch visibility warns at reload
and boot with the mode-correct cause, gated on OIDC being enabled.

Breaking: a stored `entra_obo` alias on a deployment whose
`[oidc] obo_grant_profile` is `rfc8693` (or the inverse pairing) no
longer mints via the profile-driven overload — the mint refuses before
any IdP traffic with cause `grant_profile_mismatch`, and the
`model.auth_fail_closed` policy governs static fallback. Such rows
never minted usefully on scope-gating IdPs; the shelf now surfaces the
pairing and the per-turn heartbeat names the refusal cause.

Live-verified end to end: scoped token exchange mints, the warm cache
serves with zero IdP calls, and the mode/profile mismatch refuses with
zero IdP traffic (scripts/obo-e2e/keycloak_e2e.sh); the
refresh-redemption profile's E1-E7 hold via scripts/obo-e2e/entra_e2e.py.

Closes #955.
This commit is contained in:
Patrick Buckley
2026-08-04 04:49:28 -07:00
parent 2b43b8dd90
commit 8605c9783d
28 changed files with 3155 additions and 451 deletions
+32 -21
View File
@@ -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.
+41 -30
View File
@@ -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)
+7 -2
View File
@@ -452,7 +452,7 @@ CONSOLE_TEMPLATE = """<!doctype html>
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 = """<!doctype html>
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)
+74 -118
View File
@@ -17,25 +17,18 @@ Checks E1E7 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"]
app_state=app_state,
user_id=USER,
alias="model-a",
audience=cfg["AUD_A"],
scopes=cfg.get("SCOPE_A", ""),
grant_leg="rfc8693",
)
finally:
mcp_oauth_module.log = log_hook.inner
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"
@@ -372,13 +309,32 @@ async def _run(cfg: dict[str, str], refresh_token: str) -> None:
f"{client.posts - posts_before} (want 0) synthetic_row="
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")
# 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__":
+56 -2
View File
@@ -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"
+676 -5
View File
@@ -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"))
+49
View File
@@ -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}"
+6
View File
@@ -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"
+708 -30
View File
@@ -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
+237
View File
@@ -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 "", ""
)
+24
View File
@@ -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
# ---------------------------------------------------------------------------
+31 -5
View File
@@ -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):
+249 -27
View File
@@ -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,12 +11786,20 @@ 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")
# 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 = (
caps_changed and _canonical_capabilities(existing.get("capabilities")) is not None
@@ -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,13 +11951,49 @@ def _validate_dynamic_model_auth(
},
status_code=400,
)
if auth_mode == "entra_app" and profile != "entra":
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 'entra_app' requires [oidc] obo_grant_profile='entra' "
f"(configured: {profile!r}); RFC 8693 client-credentials is not "
"supported"
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
)
)
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,
@@ -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)
+151 -39
View File
@@ -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,8 +7067,10 @@ function _syncModelAuthFields() {
if (modeHint) {
modeHint.textContent = !editable
? "needs the MCP admin permission to change"
: persistedMismatch
? "saved mode doesn't match this deployment's sign-in profile — it will not mint until changed"
: unavailable
? "app identity needs the deployment to sign in through Entra"
? "greyed-out modes need a different sign-in profile than this deployment uses"
: "";
}
if (audHint) {
@@ -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;
}
+16
View File
@@ -1716,6 +1716,9 @@
<option value="entra_app">
This deployment (entra_app)
</option>
<option value="rfc8693_obo">
Each user, via token exchange (rfc8693_obo)
</option>
</select>
</div>
<div>
@@ -1739,6 +1742,19 @@
<datalist id="model-obo-audience-options"></datalist>
</div>
</div>
<div class="field-pair">
<div>
<label for="model-obo-scopes"
>Token-exchange scopes
<span class="label-hint" id="model-obo-scopes-hint"></span
></label>
<input
type="text"
id="model-obo-scopes"
placeholder="space-separated scopes"
/>
</div>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="model-enabled" checked />
+9
View File
@@ -1053,6 +1053,15 @@ def required_scope(method: str, path: str) -> str:
):
return "approve"
# The node's model-status readout carries per-alias backend-auth
# configuration (auth mode, OBO audience, exchange scopes) — data the
# console serves only behind admin permissions — so this GET is
# classified with the admin endpoints instead of falling to the read
# default. Service tokens carry ``approve``, so the console collector's
# fan-out and scheduler lanes pass unchanged.
if normalized == "/api/_internal/model-status":
return "approve"
# Write endpoints
if method == "POST" and normalized in WRITE_PATHS:
return "write"
+59 -15
View File
@@ -53,6 +53,7 @@ from turnstone.core.mcp_http_parsers import (
parse_www_authenticate_scope,
)
from turnstone.core.mcp_oauth import (
MintDispatchContractError,
TokenLookupResult,
emit_oauth_failure_audit,
get_obo_access_token_classified,
@@ -1019,23 +1020,41 @@ class MCPClientManager:
app_state.obo_http_client = self._model_auth_http_client
def mint_model_obo_token_sync(
self, *, user_id: str, audience: str, timeout: float = 20.0
self,
*,
user_id: str,
alias: str,
audience: str,
scopes: str = "",
grant_leg: str | None = None,
timeout: float = 20.0,
) -> str | None:
"""Resolve a per-user model-provider OBO access token synchronously.
Bridges the sync agent/model loop to :func:`mint_obo_access_token` on
the mcp-loop (same thread that owns the token store's asyncio locks),
mirroring ``call_tool_sync``. Returns ``None`` on any failure no
credential, mint rejected, OAuth unwired, timeout so the model call
falls back to the backend's static credential.
mirroring ``call_tool_sync``. ``alias`` is the owning model
definition the mint's cache and cause records key on it — while
``scopes`` and ``grant_leg`` pass through untouched: the
mode-specific exchange-scope request and the leg the caller's auth
mode pins. Returns ``None`` on any failure no credential, mint
rejected, OAuth unwired, timeout so the model call falls back to
the backend's static credential.
"""
if not user_id or not audience:
if not user_id or not alias or not audience:
return None
loop = self._loop
if loop is None or self._app_state is None:
return None
future = asyncio.run_coroutine_threadsafe(
mint_obo_access_token(app_state=self._app_state, user_id=user_id, audience=audience),
mint_obo_access_token(
app_state=self._app_state,
user_id=user_id,
alias=alias,
audience=audience,
scopes=scopes,
grant_leg=grant_leg,
),
loop,
)
try:
@@ -1043,45 +1062,70 @@ class MCPClientManager:
except concurrent.futures.TimeoutError:
future.cancel()
log.warning(
"model obo token mint timed out user=%s audience=%s",
"model obo token mint timed out user=%s alias=%s audience=%s",
user_id,
alias,
audience,
)
return None
except MintDispatchContractError:
# The mint's dedicated caller-contract type (scopes without the
# exchange leg pinned, over-length scopes) — a programming error
# at the dispatch site that must not be demoted to debug. Any
# OTHER ValueError is an ordinary mint failure and falls through
# to the blanket arm below.
log.error(
"model obo token mint contract violation user=%s alias=%s audience=%s",
user_id,
alias,
audience,
exc_info=True,
)
return None
except Exception:
log.debug(
"model obo token mint failed user=%s audience=%s",
"model obo token mint failed user=%s alias=%s audience=%s",
user_id,
alias,
audience,
exc_info=True,
)
return None
def mint_app_token_sync(self, *, audience: str, timeout: float = 20.0) -> str | None:
def mint_app_token_sync(
self, *, alias: str, audience: str, timeout: float = 20.0
) -> str | None:
"""Resolve an app-identity (client-credentials) model token synchronously.
The ``auth_mode='entra_app'`` sibling of ``mint_model_obo_token_sync``:
bridges to :func:`mint_app_access_token` on the mcp-loop. No user needed
Turnstone's own SSO app registration is the identity. Returns ``None``
on any failure so the model call falls back to the static credential.
Turnstone's own SSO app registration is the identity; ``alias`` is
the owning model definition the cache and cause records key on.
Returns ``None`` on any failure so the model call falls back to the
static credential.
"""
if not audience:
if not alias or not audience:
return None
loop = self._loop
if loop is None or self._app_state is None:
return None
future = asyncio.run_coroutine_threadsafe(
mint_app_access_token(app_state=self._app_state, audience=audience),
mint_app_access_token(app_state=self._app_state, alias=alias, audience=audience),
loop,
)
try:
return future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
log.warning("model app token mint timed out audience=%s", audience)
log.warning("model app token mint timed out alias=%s audience=%s", alias, audience)
return None
except Exception:
log.debug("model app token mint failed audience=%s", audience, exc_info=True)
log.debug(
"model app token mint failed alias=%s audience=%s",
alias,
audience,
exc_info=True,
)
return None
def invalidate_model_mint_memo_sync(
+375 -99
View File
@@ -48,6 +48,11 @@ from turnstone.core.mcp_http_parsers import (
is_valid_scope_token,
parse_www_authenticate_bearer,
)
from turnstone.core.model_registry import (
MODEL_AUTH_TEXT_MAX_LEN,
sanitize_backend_auth_scopes,
strip_control_characters,
)
from turnstone.core.oauth_ssrf import (
OAuthSSRFError,
sanitize_log_text,
@@ -2762,45 +2767,66 @@ def _warn_dedup_once(warned: set[_DedupKey], key: _DedupKey, event: str, **field
log.warning(event, **fields)
# Last-known mint refusal cause per (prefix, audience, user). The CAUSE
# Last-known mint refusal cause per (prefix, cause-key, user). The CAUSE
# layer above is deduped to once per process, so this record lets the
# DECISION layer (the session's per-turn heartbeat) name the cause inline on
# every occurrence without re-amplifying the deduped warning. The key
# carries the USER: on a shared audience, one user's successful mint must
# not clear another's recorded cause, nor stamp its own into another's
# heartbeat — app-identity mints record under the shared
# every occurrence without re-amplifying the deduped warning. The cause key
# is the alias-keyed cache key (plus the pinned leg for OBO mints), so two
# aliases can never cross-stamp. The key carries the USER: one user's
# successful mint must not clear another's recorded cause, nor stamp its own
# into another's heartbeat — app-identity mints record under the shared
# MODEL_APP_MINT_PRINCIPAL. Written at every refusal diagnosis even when the
# warning was dedup-suppressed; cleared only by the recording user's
# successful mint. Capped like the per-user warn set. Tuple keys — user ids
# and api:// audiences can both contain ``:``.
# successful mint. Tuple keys — user ids and cache keys can both contain
# ``:``.
_MODEL_MINT_LAST_CAUSE: dict[tuple[str, str, str], str] = {}
# The cause map's OWN bound, not the warn-set's: keys are per
# (prefix, cause-key, user) and the cause key carries the alias and leg
# axes, so cardinality is users x aliases — the 512-entry warn-set cap
# starves real diagnostics at deployment scale. Records are readback state,
# not log lines, so when full the LEAST-RECENTLY-STAMPED entry is evicted
# rather than the newest dropped: the map always records the latest refusal.
_CAUSE_RECORD_CAP = 4096
def _record_mint_refusal_cause(prefix: str, audience: str, user_id: str, cause: str) -> None:
key = (prefix, audience, user_id)
if key not in _MODEL_MINT_LAST_CAUSE and len(_MODEL_MINT_LAST_CAUSE) >= _WARN_DEDUP_CAP:
return
def _record_mint_refusal_cause(prefix: str, cause_key: str, user_id: str, cause: str) -> None:
# ``cause_key`` matches the reader's contract: the alias-keyed cache key
# for model_app, the model_obo_cause_key spelling for model_obo.
key = (prefix, cause_key, user_id)
# Re-stamps move the record to the newest insertion position (dict
# overwrite would keep it at its ORIGINAL slot), so eviction below hits
# the least-recently-stamped record — never the hottest one an operator
# is actively debugging.
_MODEL_MINT_LAST_CAUSE.pop(key, None)
if len(_MODEL_MINT_LAST_CAUSE) >= _CAUSE_RECORD_CAP:
del _MODEL_MINT_LAST_CAUSE[next(iter(_MODEL_MINT_LAST_CAUSE))]
_MODEL_MINT_LAST_CAUSE[key] = cause
# Cooldown short-circuits need no re-stamp: the cause persists until its user's mint succeeds.
def _clear_mint_refusal_cause(prefix: str, audience: str, user_id: str) -> None:
_MODEL_MINT_LAST_CAUSE.pop((prefix, audience, user_id), None)
def _clear_mint_refusal_cause(prefix: str, cause_key: str, user_id: str) -> None:
_MODEL_MINT_LAST_CAUSE.pop((prefix, cause_key, user_id), None)
def model_mint_refusal_cause(prefix: str, audience: str, user_id: str) -> str:
"""Best-effort cause of the most recent refused mint for *audience*.
def model_mint_refusal_cause(prefix: str, cause_key: str, user_id: str) -> str:
"""Best-effort cause of the most recent refused mint under *cause_key*.
``prefix`` is ``"model_obo"`` or ``"model_app"``; ``user_id`` is the
``prefix`` is ``"model_obo"`` or ``"model_app"``; ``cause_key`` is
:func:`model_app_cache_server`'s key for app mints and the full
:func:`model_obo_cause_key` key for OBO mints the record shares the
cache key's per-alias granularity plus the pinned leg, so two aliases
(or two mode-variants of one alias history) never cross-stamp, and
readers must build the key with those helpers. ``user_id`` is the
minting principal the cause was recorded under the acting user for
OBO mints, :data:`MODEL_APP_MINT_PRINCIPAL` for app-identity mints.
Returns ``""`` when no refusal has been recorded in this process for
that principal (or their mint has succeeded since); callers render that
as unknown.
"""
return _MODEL_MINT_LAST_CAUSE.get((prefix, audience, user_id), "")
return _MODEL_MINT_LAST_CAUSE.get((prefix, cause_key, user_id), "")
def reset_model_mint_warn_state_for_tests() -> None:
@@ -2817,25 +2843,32 @@ def reset_model_mint_warn_state_for_tests() -> None:
_MODEL_MINT_LAST_CAUSE.clear()
def _warn_model_mint_misconfig_once(event: str, audience: str, user_id: str, **fields: Any) -> None:
def _warn_model_mint_misconfig_once(
event: str, audience: str, user_id: str, *, cause_key: str, **fields: Any
) -> None:
# Record the cause FIRST, unconditionally: the warning below is deduped,
# but the heartbeat's readback must reflect every occurrence, attributed
# to the principal whose mint was refused.
# to the principal whose mint was refused. ``cause_key`` is REQUIRED and
# names the reader's exact key — model_app_cache_server's key for app
# mints, the model_obo_cause_key spelling for OBO mints — because the
# cause record must share the reader's granularity or two callers
# cross-stamp and cross-clear each other's causes. The human-facing warn
# keeps the PLAIN audience for its dedup key and log field.
prefix, _, cause = event.partition(".")
_record_mint_refusal_cause(prefix, audience, user_id, cause)
_record_mint_refusal_cause(prefix, cause_key, user_id, cause)
_warn_dedup_once(
_MODEL_MINT_MISCONFIG_WARNED, f"{event}:{audience}", event, audience=audience, **fields
)
def _warn_model_obo_missing_credential_once(audience: str, user_id: str) -> None:
def _warn_model_obo_missing_credential_once(audience: str, user_id: str, *, cause_key: str) -> None:
"""Name the missing-credential cause once per (user, audience).
``user_id`` deliberately stays IN the log line: this is an auth event,
and remediation link THIS user's OIDC sign-in — needs the principal,
the same practice as the audit rows, which carry ids.
"""
_record_mint_refusal_cause("model_obo", audience, user_id, "missing_credential")
_record_mint_refusal_cause("model_obo", cause_key, user_id, "missing_credential")
_warn_dedup_once(
_MODEL_OBO_MISSING_CRED_WARNED,
(user_id, audience),
@@ -2845,7 +2878,9 @@ def _warn_model_obo_missing_credential_once(audience: str, user_id: str) -> None
)
def _warn_mint_oidc_cause(prefix: str, oidc_config: Any, audience: str, user_id: str) -> None:
def _warn_mint_oidc_cause(
prefix: str, oidc_config: Any, audience: str, user_id: str, *, cause_key: str
) -> None:
"""Name the OIDC-not-ready cause for a model mint, once per (cause, audience).
Single-sourced for both mints (``model_obo``/``model_app`` prefix) so the
@@ -2856,13 +2891,23 @@ def _warn_mint_oidc_cause(prefix: str, oidc_config: Any, audience: str, user_id:
not-enabled dedup slot on a transient.
"""
if getattr(oidc_config, "discovery_retryable", False):
_warn_model_mint_misconfig_once(f"{prefix}.oidc_discovery_pending", audience, user_id)
_warn_model_mint_misconfig_once(
f"{prefix}.oidc_discovery_pending", audience, user_id, cause_key=cause_key
)
else:
_warn_model_mint_misconfig_once(f"{prefix}.oidc_not_enabled", audience, user_id)
_warn_model_mint_misconfig_once(
f"{prefix}.oidc_not_enabled", audience, user_id, cause_key=cause_key
)
def _warn_mint_store_unavailable(
prefix: str, audience: str, user_id: str, token_store: Any, storage: Any
prefix: str,
audience: str,
user_id: str,
token_store: Any,
storage: Any,
*,
cause_key: str,
) -> None:
"""Name the missing token-store/storage cause, once per (cause, audience).
@@ -2873,6 +2918,7 @@ def _warn_mint_store_unavailable(
f"{prefix}.token_store_unavailable",
audience,
user_id,
cause_key=cause_key,
has_token_store=token_store is not None,
has_storage=storage is not None,
)
@@ -3007,55 +3053,226 @@ def _prune_model_mint_lock_when_idle(
asyncio.get_running_loop().call_soon(_drop_if_idle)
def _model_obo_cache_server(audience: str) -> str:
"""Synthetic ``mcp_user_tokens`` server key for a model audience's mint-cache row.
# The unit separator joins the structural axes of synthetic key spellings:
# the digest marker in _bounded_synthetic_key's over-bound arm, and the
# grant-leg suffix in model_obo_cause_key. It cannot appear in the joined
# values — the key builders strip control characters from the alias and the
# leg names are literals — so a separator-joined spelling can never collide
# with a plain one. A PRINTABLE separator could: aliases legally contain
# ``.`` and ``-``, and DB-direct rows could carry anything. Rendered
# surfaces show it JSON-escaped (U+001F); the cache row's own
# ``audience``/``scopes`` columns stay the legible record.
_SYNTHETIC_KEY_SEP = chr(0x1F)
The table's PK is ``(user_id, server_name)`` and model audiences vary, so the
audience is embedded in the key mirroring the in-process ``(user, audience)``
key and the ``__model_obo__:<audience>`` single-flight lock. The ``__model_obo__:``
prefix keeps these cache rows distinguishable from real oauth_user server rows.
# Byte bound for the synthetic mint-cache keys: ``mcp_user_tokens.server_name``
# is half the table's PRIMARY KEY and btree-indexed, and PostgreSQL's index
# tuple limit is ~2704 bytes — a key at most 2600 UTF-8 bytes stays safely
# under it. Console-written aliases are capped at 64 ASCII characters, so
# only a DB-direct row can ever reach the bound.
_SYNTHETIC_KEY_MAX_BYTES = 2600
def _bounded_synthetic_key(prefix: str, remainder: str) -> str:
"""``prefix + remainder``, or its digest spelling past the byte bound.
Takes the prefix and remainder separately and joins internally, so the
digest spelling keeps the builder's *prefix* by construction — bounded
keys stay classified (model-OBO vs model-app rows) and prefix-scan
consumers (deprovisioning, obo_server_names) always match them. The
digest arm's separator-after-prefix shape is disjoint from every
literal key the callers strip control characters from *remainder*.
"""
return f"{MODEL_OBO_CACHE_PREFIX}{audience}"
candidate = f"{prefix}{remainder}"
if len(candidate.encode("utf-8")) <= _SYNTHETIC_KEY_MAX_BYTES:
return candidate
whole = hashlib.sha256(candidate.encode("utf-8")).hexdigest()[:48]
return f"{prefix}{_SYNTHETIC_KEY_SEP}{whole}"
class MintDispatchContractError(ValueError):
"""A model-mint call site violated the dispatch contract.
Raised ONLY for call-site contract violations scopes without the
exchange leg pinned, over-length scopes from a raw caller never for
operator state: misconfiguration, missing credentials and rejected
grants all return ``None`` with a recorded cause instead. The sync
bridge surfaces this type at ERROR while demoting ordinary mint
failures to debug.
"""
def _normalized_mint_scopes(scopes: Any) -> str:
"""The mint stack's one scopes normalization.
Delegates to :func:`sanitize_backend_auth_scopes` the shared spelling,
under which whitespace separators become single spaces (never
concatenation) and no control character survives from any caller.
Idempotent, so registry- and console-normalized values pass through
unchanged; raw values from direct callers (harness, tests) land on the
same spelling the mint sends to the IdP, stores in the cache row's
``scopes`` column, and compares in the freshness gate. Over-length
input raises
:class:`MintDispatchContractError` rather than silently truncating:
every production path is registry/console-bounded first, so an over-cap
value here is a raw call site's bug, and a silent slice would mint a
narrower privilege request than the caller asked for.
"""
if not scopes:
return ""
normalized = sanitize_backend_auth_scopes(scopes)
if len(normalized) > MODEL_AUTH_TEXT_MAX_LEN:
raise MintDispatchContractError(
f"mint scopes exceed MODEL_AUTH_TEXT_MAX_LEN ({MODEL_AUTH_TEXT_MAX_LEN} characters)"
)
return normalized
def model_obo_cache_server(alias: str) -> str:
"""Synthetic ``mcp_user_tokens`` server key for a model alias's mint-cache row.
Public: the session heartbeat and the e2e harness build the same key to
read the per-alias refusal-cause record, which shares this key's
granularity.
IDENTITY-KEYED, exactly like real MCP rows: ``mcp_servers.name`` is
unique and its OAuth rows key on it, and a model definition's unique
``alias`` plays the same role here one owning definition per key, so
the admin lifecycle purge (rename, re-aim, delete) deletes only rows the
edited definition owns, never a sibling's. The minted bearer's SHAPE
(audience + scopes) deliberately does NOT enter the key: it lives in the
row's ``audience``/``scopes`` columns, where the freshness gate compares
it against the definition's current values on every read — so a re-aimed
alias refuses its old row immediately and the next mint overwrites it in
place, with no stranded key. Memoization and the single-flight lock
derive from this key, so one broken alias can never suppress another
even two aliases fronting the same gateway; cooldown and backoff key on
this key PLUS the dispatch shape (see the mint bodies), so a config
repair is an instant clean slate rather than waiting out a cooldown the
superseded shape armed.
Console-written aliases match ``[a-zA-Z0-9._-]{1,64}``, so the key is
short ASCII and always literal. The strip + byte bound below only defend
the raw-caller seam (DB-direct rows, tests): control characters are
removed so the digest spelling of :func:`_bounded_synthetic_key` stays
disjoint from every literal key, and an over-bound alias collapses to
that digest spelling rather than breaking index persistence.
"""
return _bounded_synthetic_key(MODEL_OBO_CACHE_PREFIX, strip_control_characters(alias))
def model_obo_cause_key(alias: str, grant_leg: str | None = None) -> str:
"""The refusal-cause record's key for a model-OBO mint.
:func:`model_obo_cache_server`'s per-alias granularity plus the pinned
``grant_leg``, so an alias edited across modes cannot cross-stamp its
own history a legged mint's refusal is recorded, cleared, and read
under its own leg. Leg-``None`` callers get the plain cache key, so
legacy profile-driven mints keep their records.
"""
key = model_obo_cache_server(alias)
if grant_leg:
return f"{key}{_SYNTHETIC_KEY_SEP}{grant_leg}"
return key
async def mint_obo_access_token(
*,
app_state: Any,
user_id: str,
alias: str,
audience: str,
scopes: str = "",
grant_leg: str | None = None,
force_refresh: bool = False,
) -> str | None:
"""Per-user Entra OBO access token for an arbitrary resource *audience*.
"""Per-user delegated access token for the model definition *alias*.
Redeems the user's captured refresh credential (``oidc_user_credentials``)
for *audience* via the configured OBO grant profile, persists any rotated
refresh token (value CAS, cluster-locked exactly like the MCP mint), and
caches the minted access token in an ``mcp_user_tokens`` mint-cache row
(``refresh_token=NULL``) keyed ``__model_obo__:<audience>`` the same
(``refresh_token=NULL``) keyed ``__model_obo__:<alias>`` the same
"cache, not custody" row the classified path uses so the token is shared
across worker nodes and inspectable, until shortly before expiry.
across worker nodes and inspectable, until shortly before expiry. The key
is IDENTITY-keyed on the owning definition (see
:func:`model_obo_cache_server`); the minted bearer's shape lives in the
row's ``audience``/``scopes`` columns, which the freshness gate compares
against the values passed here so after a definition is re-aimed the
old row refuses immediately and the next mint overwrites it in place.
``scopes`` is threaded to the mint leg exactly as an MCP row's
``oauth_scopes`` is: the rfc8693 exchange leg requests it (exchange-capable
IdPs refuse an audience whose scope was not requested), the entra leg
ignores it by design.
``grant_leg`` pins the leg the CALLER's auth mode names (``"entra"`` /
``"rfc8693"``); when the deployment's configured profile differs, the mint
refuses with a ``grant_profile_mismatch`` cause and no IdP traffic a
mode is a dialect commitment, not a hint. ``None`` keeps the legacy
profile-driven dispatch.
Returns ``None`` the signal for callers to fall back to their static
credential when OIDC is disabled/unconfigured, the profile has no mint
leg, the user has no captured credential (or it won't decrypt), or the mint
is rejected. This is the model-provider entry point; the MCP-server path
uses :func:`get_obo_access_token_classified`, which additionally owns
leg or contradicts ``grant_leg``, the user has no captured credential (or
it won't decrypt), or the mint is rejected. This is the model-provider
entry point; the MCP-server path uses
:func:`get_obo_access_token_classified`, which additionally owns
per-server cache rows, dead-grant classification, and consent affordances
this helper deliberately omits.
"""
if not user_id or not audience:
# The registry normalizer rejects control characters and the console
# write path strips them; stripping here too closes the raw-direct-caller
# seam (harness, tests) exactly like the scopes strip below, so no
# control byte can reach the cache row or the IdP request. Controls
# first, THEN whitespace — an edge control character must not shield
# the edge whitespace behind it (the same ordering _clean_oauth_text
# uses). The alias is stripped inside the key builder for the same
# reason.
audience = strip_control_characters(str(audience or "")).strip()
alias = str(alias or "").strip()
if not user_id or not alias or not audience:
return None
scopes = _normalized_mint_scopes(scopes)
if scopes and grant_leg != "rfc8693":
# Caller contract, not operator config: only the token-exchange leg
# reads a scope request, so scopes without that leg pinned means the
# call site's dispatch is wrong — minting anyway would either run an
# unpinned leg the row's mode never committed to, or hand the entra
# leg scopes it ignores while the cache row claims them. Raise
# rather than the operator-facing None every config refusal uses.
raise MintDispatchContractError("mint_obo_access_token: scopes require grant_leg='rfc8693'")
# Cache row, cooldown and single-flight lock all key on the owning
# alias: two definitions are separate mint identities end to end even
# when they front the same gateway, so one's success or failure can
# never clear, suppress, or serve the other's.
cache_server = model_obo_cache_server(alias)
# The refusal-cause record additionally keys on the pinned leg: an
# alias edited across modes must not overwrite its other leg's cause,
# and the session heartbeat builds the same key.
cause_key = model_obo_cause_key(alias, grant_leg)
# Cooldown/backoff key on (alias, SHAPE), never the shared credential
# key: the alias axis keeps one broken definition from suppressing a
# sibling, and the shape axis makes an operator's config repair an
# instant clean slate — a corrected audience or scopes spells a
# different key with no armed state, so the first retry mints
# immediately instead of waiting out a cooldown armed by the superseded
# shape (in-process state is per node; the console purge reaches only
# DB rows). The cache row and the single-flight lock deliberately stay
# on the identity key: one row, one mint at a time, whatever the shape.
cooldown_key = f"{cache_server}{_SYNTHETIC_KEY_SEP}{audience}{_SYNTHETIC_KEY_SEP}{scopes}"
# The caller's ``fallback_to_static`` is the DECISION layer and fires per
# turn but names no cause; these branches are the CAUSE layer, deduped to
# once per (cause, audience) per process.
oidc_config = getattr(app_state, "oidc_config", None)
if oidc_config is None or not getattr(oidc_config, "enabled", False):
_warn_mint_oidc_cause("model_obo", oidc_config, audience, user_id)
_warn_mint_oidc_cause("model_obo", oidc_config, audience, user_id, cause_key=cause_key)
return None
token_store: MCPTokenStore | None = getattr(app_state, "mcp_token_store", None)
storage = _get_storage(app_state)
if token_store is None or storage is None:
_warn_mint_store_unavailable("model_obo", audience, user_id, token_store, storage)
_warn_mint_store_unavailable(
"model_obo", audience, user_id, token_store, storage, cause_key=cause_key
)
return None
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
mint = _OBO_MINT_LEGS.get(profile)
@@ -3063,30 +3280,53 @@ async def mint_obo_access_token(
# Deployment-stable, per-call-per-lane — same dedup rationale as the
# sibling causes.
_warn_model_mint_misconfig_once(
"model_obo.unsupported_grant_profile", audience, user_id, profile=profile
"model_obo.unsupported_grant_profile",
audience,
user_id,
cause_key=cause_key,
alias=alias,
profile=profile,
)
return None
if grant_leg is not None and profile != grant_leg:
# The mode's dialect and the deployment's dialect disagree — running
# the profile's leg anyway would send a request the mode's IdP shape
# never satisfies (the pre-dedicated-mode overload #955 closed).
# Refused before any IdP traffic, same cause taxonomy as the app
# mint's profile refusal. Surfaces as None like every refusal here:
# the session's static-fallback policy (model.auth_fail_closed)
# governs what happens next, deliberately not a special case — a
# RULED disposition, including for pre-#955 rows that relied on the
# profile-driven overload and land here after upgrade (they never
# minted on a scope-gating IdP; docs/oidc.md's pairing section and
# the per-turn heartbeat's cause= readback carry the operator
# signal).
_warn_model_mint_misconfig_once(
"model_obo.grant_profile_mismatch",
audience,
user_id,
cause_key=cause_key,
alias=alias,
profile=profile,
required_profile=grant_leg,
)
return None
issuer = str(getattr(oidc_config, "issuer", "") or "")
cache_server = _model_obo_cache_server(audience)
cached_token = await _serve_fresh_mint_cache(
app_state=app_state,
token_store=token_store,
user_id=user_id,
cache_server=cache_server,
audience=audience,
scopes="",
scopes=scopes,
)
if cached_token and not force_refresh:
_clear_refresh_backoff(app_state, user_id, cache_server)
_clear_refresh_backoff(app_state, user_id, cooldown_key)
return cached_token
# The model cache key is intentionally the cooldown key. The shared
# ``__obo__:<issuer>`` credential key is also used by MCP OBO dispatch;
# arming it here would let one broken model audience suppress every OBO
# tool for this user.
if not cached_token and _refresh_in_cooldown(app_state, user_id, cache_server):
if not cached_token and _refresh_in_cooldown(app_state, user_id, cooldown_key):
return None
# Single-flight the mint: a per-(user, audience) asyncio lock for local
# Single-flight the mint: a per-(user, alias) asyncio lock for local
# coalescing, then the SAME per-(user, issuer) credential lock + cluster
# advisory lock the MCP mint takes — the refresh credential is the shared
# mutable resource (rotation write-back), so a model mint and an MCP mint for
@@ -3104,12 +3344,12 @@ async def mint_obo_access_token(
user_id=user_id,
cache_server=cache_server,
audience=audience,
scopes="",
scopes=scopes,
)
if cached_token and not force_refresh:
_clear_refresh_backoff(app_state, user_id, cache_server)
_clear_refresh_backoff(app_state, user_id, cooldown_key)
return cached_token
if not cached_token and _refresh_in_cooldown(app_state, user_id, cache_server):
if not cached_token and _refresh_in_cooldown(app_state, user_id, cooldown_key):
return None
credential = await _read_obo_credential(
@@ -3126,7 +3366,7 @@ async def mint_obo_access_token(
# sign-in, so no captured credential to redeem. Deduped
# in its OWN namespace so a large user population cannot
# saturate the operator-config cause set.
_warn_model_obo_missing_credential_once(audience, user_id)
_warn_model_obo_missing_credential_once(audience, user_id, cause_key=cause_key)
elif credential.kind == "decrypt_failure":
# The credential exists but decrypts under no active key
# (the keyring rotated away from it). Recording the cause
@@ -3134,9 +3374,9 @@ async def mint_obo_access_token(
# keeps the heartbeat from rendering this class as
# unknown, as every sibling refusal exit does.
_record_mint_refusal_cause(
"model_obo", audience, user_id, "credential_decrypt_failure"
"model_obo", cause_key, user_id, "credential_decrypt_failure"
)
_arm_cooldown(app_state, user_id, cache_server)
_arm_cooldown(app_state, user_id, cooldown_key)
return None
async def _persist_rotation(new_credential_rt: str) -> None:
@@ -3165,16 +3405,17 @@ async def mint_obo_access_token(
oidc_config=oidc_config,
credential_refresh_token=credential["refresh_token"],
audience=audience,
scopes="",
scopes=scopes,
http_client=mint_client,
persist_rotation=_persist_rotation,
)
except MCPOAuthRefreshFailed:
_arm_cooldown(app_state, user_id, cache_server)
_record_mint_refusal_cause("model_obo", audience, user_id, "mint_failed")
_arm_cooldown(app_state, user_id, cooldown_key)
_record_mint_refusal_cause("model_obo", cause_key, user_id, "mint_failed")
log.warning(
"model_obo.mint_failed",
user_id=user_id,
alias=alias,
audience=audience,
exc_info=True,
)
@@ -3182,9 +3423,9 @@ async def mint_obo_access_token(
access_token = tokens.get("access_token")
if not isinstance(access_token, str) or not access_token:
_arm_cooldown(app_state, user_id, cache_server)
_arm_cooldown(app_state, user_id, cooldown_key)
_record_mint_refusal_cause(
"model_obo", audience, user_id, "mint_missing_access_token"
"model_obo", cause_key, user_id, "mint_missing_access_token"
)
log.warning(
"model_obo.mint_missing_access_token",
@@ -3202,7 +3443,7 @@ async def mint_obo_access_token(
cache_server,
access_token=access_token,
expires_at=expires_at,
scopes="",
scopes=scopes,
issuer=issuer,
audience=audience,
)
@@ -3219,15 +3460,16 @@ async def mint_obo_access_token(
cache_server=cache_server,
access_token=access_token,
expires_at=expires_at,
scopes="",
scopes=scopes,
issuer=issuer,
audience=audience,
)
_clear_refresh_backoff(app_state, user_id, cache_server)
_clear_mint_refusal_cause("model_obo", audience, user_id)
_clear_refresh_backoff(app_state, user_id, cooldown_key)
_clear_mint_refusal_cause("model_obo", cause_key, user_id)
log.info(
"model_obo.minted",
user_id=user_id,
alias=alias,
audience=audience,
cache_server=cache_server,
)
@@ -3242,26 +3484,34 @@ async def mint_obo_access_token(
# The ``auth_mode='entra_app'`` sibling of the OBO path: instead of a per-user
# On-Behalf-Of token it mints an APP token from the ``[oidc]`` client id + secret
# via the client-credentials grant. No user, no captured refresh token, no
# rotation — one token per audience, shared by everyone, so a gateway resolves it
# to a single machine (virtual-account) identity with no per-user attribution. It
# reuses the same DB mint-cache under a synthetic ``__app__`` user.
# rotation — one token per owning definition, shared by everyone, so a gateway
# resolves it to a single machine (virtual-account) identity with no per-user
# attribution. It reuses the same DB mint-cache under a synthetic ``__app__``
# user.
_APP_CACHE_USER = MODEL_APP_MINT_PRINCIPAL
def _model_app_cache_server(audience: str) -> str:
def model_app_cache_server(alias: str) -> str:
"""Synthetic ``mcp_user_tokens`` key for an app-credential mint-cache row.
App tokens carry no user, so they cache once per audience under the shared
``__app__`` pseudo-user; the ``__model_app__:`` prefix keeps them distinct
from per-user OBO rows (``__model_obo__:``) and real oauth_user server rows.
App tokens carry no user, so they cache once per owning definition under
the shared ``__app__`` pseudo-user; the ``__model_app__:`` prefix keeps
them distinct from per-user OBO rows (``__model_obo__:``) and real
oauth_user server rows. Identity-keyed on the definition's unique
``alias`` exactly like :func:`model_obo_cache_server` (whose docstring
carries the keying rationale, the freshness-gate rebind defense, and the
strip + byte-bound raw-caller seam this builder shares). Public: this
key doubles as the app mint's refusal-cause key, which the session
heartbeat rebuilds.
"""
return f"{MODEL_APP_CACHE_PREFIX}{audience}"
return _bounded_synthetic_key(MODEL_APP_CACHE_PREFIX, strip_control_characters(alias))
async def mint_app_access_token(
*,
app_state: Any,
alias: str,
audience: str,
force_refresh: bool = False,
) -> str | None:
@@ -3269,9 +3519,12 @@ async def mint_app_access_token(
Uses Turnstone's own SSO app registration (``[oidc]`` ``client_id`` +
``client_secret``) no user, no captured refresh token, no rotation. One
token per audience, shared by every caller and cached in an
``mcp_user_tokens`` row under the synthetic ``__app__`` user until shortly
before expiry. This is the ``auth_mode='entra_app'`` backend entry point
token per owning definition *alias*, shared by every caller and cached in
an ``mcp_user_tokens`` row under the synthetic ``__app__`` user until
shortly before expiry (identity-keyed like the OBO twin; the freshness
gate compares the row's stored audience against the current one, so a
re-aimed alias refuses its old row and overwrites it on the next mint).
This is the ``auth_mode='entra_app'`` backend entry point
the "we already have SSO, let the app call the gateway as its own managed
identity" path (a gateway resolves it to one virtual account, no per-user
attribution). Because it needs no user context it also serves utility /
@@ -3279,18 +3532,34 @@ async def mint_app_access_token(
signal to fall back to the static credential when OIDC is
disabled/unconfigured, the app has no secret, or the grant is rejected.
"""
if not audience:
# Same raw-caller hygiene as the OBO twin: strip control characters —
# controls first, then whitespace, so an edge control cannot shield
# edge whitespace — so no control byte reaches the cache row or the
# IdP request (the alias is stripped inside the key builder).
audience = strip_control_characters(str(audience or "")).strip()
alias = str(alias or "").strip()
if not alias or not audience:
return None
# The refusal-cause key IS the alias-keyed cache key — the session
# heartbeat rebuilds it via the public builder.
cause_key = model_app_cache_server(alias)
# The CAUSE layer, as in mint_obo_access_token: same
# once-per-(cause, audience) dedup.
oidc_config = getattr(app_state, "oidc_config", None)
if oidc_config is None or not getattr(oidc_config, "enabled", False):
_warn_mint_oidc_cause("model_app", oidc_config, audience, _APP_CACHE_USER)
_warn_mint_oidc_cause(
"model_app", oidc_config, audience, _APP_CACHE_USER, cause_key=cause_key
)
return None
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
if profile != "entra":
_warn_model_mint_misconfig_once(
"model_app.unsupported_grant_profile", audience, _APP_CACHE_USER, profile=profile
"model_app.unsupported_grant_profile",
audience,
_APP_CACHE_USER,
cause_key=cause_key,
alias=alias,
profile=profile,
)
return None
client_id = str(getattr(oidc_config, "client_id", "") or "")
@@ -3299,11 +3568,17 @@ async def mint_app_access_token(
token_store: MCPTokenStore | None = getattr(app_state, "mcp_token_store", None)
storage = _get_storage(app_state)
if token_store is None or storage is None:
_warn_mint_store_unavailable("model_app", audience, _APP_CACHE_USER, token_store, storage)
_warn_mint_store_unavailable(
"model_app", audience, _APP_CACHE_USER, token_store, storage, cause_key=cause_key
)
return None
issuer = str(getattr(oidc_config, "issuer", "") or "")
cache_server = _model_app_cache_server(audience)
cache_server = cause_key
# Cooldown/backoff key on (alias, SHAPE), matching the OBO twin: a
# corrected audience is an instant clean slate instead of waiting out a
# cooldown the superseded audience armed.
cooldown_key = f"{cache_server}{_SYNTHETIC_KEY_SEP}{audience}"
cached_token = await _serve_fresh_mint_cache(
app_state=app_state,
token_store=token_store,
@@ -3313,17 +3588,18 @@ async def mint_app_access_token(
scopes="",
)
if cached_token and not force_refresh:
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cache_server)
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cooldown_key)
return cached_token
if not cached_token and _refresh_in_cooldown(app_state, _APP_CACHE_USER, cache_server):
if not cached_token and _refresh_in_cooldown(app_state, _APP_CACHE_USER, cooldown_key):
return None
if not (client_id and client_secret and token_endpoint):
_arm_cooldown(app_state, _APP_CACHE_USER, cache_server)
_arm_cooldown(app_state, _APP_CACHE_USER, cooldown_key)
_record_mint_refusal_cause(
"model_app", audience, _APP_CACHE_USER, "credentials_unavailable"
"model_app", cause_key, _APP_CACHE_USER, "credentials_unavailable"
)
log.warning(
"model_app.credentials_unavailable",
alias=alias,
has_client_id=bool(client_id),
has_client_secret=bool(client_secret),
has_token_endpoint=bool(token_endpoint),
@@ -3331,7 +3607,7 @@ async def mint_app_access_token(
return None
# Single-flight the mint. No per-user credential to rotate, so only the
# per-audience local + cluster lock is taken (no credential lock).
# per-alias local + cluster lock is taken (no credential lock).
lock = _refresh_lock_for(app_state, _APP_CACHE_USER, cache_server)
pg_lock = await _acquire_pg_refresh_lock(storage, _APP_CACHE_USER, cache_server)
try:
@@ -3345,9 +3621,9 @@ async def mint_app_access_token(
scopes="",
)
if cached_token and not force_refresh:
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cache_server)
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cooldown_key)
return cached_token
if not cached_token and _refresh_in_cooldown(app_state, _APP_CACHE_USER, cache_server):
if not cached_token and _refresh_in_cooldown(app_state, _APP_CACHE_USER, cooldown_key):
return None
try:
@@ -3364,18 +3640,18 @@ async def mint_app_access_token(
leg="client-credentials",
)
except MCPOAuthRefreshFailed:
_arm_cooldown(app_state, _APP_CACHE_USER, cache_server)
_record_mint_refusal_cause("model_app", audience, _APP_CACHE_USER, "mint_failed")
log.warning("model_app.mint_failed", audience=audience, exc_info=True)
_arm_cooldown(app_state, _APP_CACHE_USER, cooldown_key)
_record_mint_refusal_cause("model_app", cause_key, _APP_CACHE_USER, "mint_failed")
log.warning("model_app.mint_failed", alias=alias, audience=audience, exc_info=True)
return None
access_token = tokens.get("access_token")
if not isinstance(access_token, str) or not access_token:
_arm_cooldown(app_state, _APP_CACHE_USER, cache_server)
_arm_cooldown(app_state, _APP_CACHE_USER, cooldown_key)
_record_mint_refusal_cause(
"model_app", audience, _APP_CACHE_USER, "mint_missing_access_token"
"model_app", cause_key, _APP_CACHE_USER, "mint_missing_access_token"
)
log.warning("model_app.mint_missing_access_token", audience=audience)
log.warning("model_app.mint_missing_access_token", alias=alias, audience=audience)
return None
expires_at = _expires_at_from_response(
tokens, default_ttl_seconds=_OBO_DEFAULT_TTL_SECONDS
@@ -3403,9 +3679,9 @@ async def mint_app_access_token(
issuer=issuer,
audience=audience,
)
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cache_server)
_clear_mint_refusal_cause("model_app", audience, _APP_CACHE_USER)
log.info("model_app.minted", audience=audience, cache_server=cache_server)
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cooldown_key)
_clear_mint_refusal_cause("model_app", cause_key, _APP_CACHE_USER)
log.info("model_app.minted", alias=alias, audience=audience, cache_server=cache_server)
return access_token
finally:
_prune_model_mint_lock_when_idle(app_state, _APP_CACHE_USER, cache_server, lock)
+202 -17
View File
@@ -7,8 +7,10 @@ resilience when the primary model is unreachable.
from __future__ import annotations
import re
import threading
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -20,7 +22,12 @@ from turnstone.core.providers import LLMProvider, create_client, create_provider
log = get_logger(__name__)
MODEL_AUTH_MODES = frozenset({"static", "entra_obo", "entra_app"})
MODEL_AUTH_MODES = frozenset({"static", "entra_obo", "entra_app", "rfc8693_obo"})
# One bound for the backend-auth text columns (obo_audience, obo_scopes),
# shared with the console write path's cleaner so the two layers cannot
# drift into "console stores it, registry refuses to load it".
MODEL_AUTH_TEXT_MAX_LEN = 2048
# Derived, never hand-listed (fail-safe defaults): any mode later added to
# MODEL_AUTH_MODES lands in this set BY CONSTRUCTION unless it is literally
@@ -30,6 +37,34 @@ MODEL_AUTH_MODES = frozenset({"static", "entra_obo", "entra_app"})
# static and skips the write gate entirely.
DYNAMIC_MODEL_AUTH_MODES = frozenset(MODEL_AUTH_MODES) - {"static"}
# Modes whose mint reads ``obo_scopes`` (the RFC 8693 exchange-leg scope
# request). The console write path refuses a NEW non-empty obo_scopes on any
# other mode, and the session dispatch passes scopes to the mint only for
# members, so a value stored outside this set is inert by construction.
SCOPES_MODEL_AUTH_MODES = frozenset({"rfc8693_obo"})
# Modes that mint as the deployment's own app identity — no acting user
# required. Every OTHER dynamic mode delegates the acting user's credential,
# so the session's no-user-context refusal derives from this set's
# complement: an unclassified future mode demands a user and fails closed
# (loudly wrong for an app-identity mode, silently unsafe never).
APP_IDENTITY_MODEL_AUTH_MODES = frozenset({"entra_app"})
# Required ``[oidc] obo_grant_profile`` per dynamic mode — the type-pairing
# the console validator enforces when a write CHOOSES a pair, and the grant
# leg the session pins at mint time. Values are spelled as literals rather
# than imported from mcp_oauth's OBO_GRANT_PROFILES: lightweight consumers
# import this module and must not pull the mint stack with it; the
# registry-vs-legs agreement (and full coverage of the dynamic set) is
# pinned by test_model_auth_mode_profile_map_matches_mint_legs.
MODEL_AUTH_MODE_PROFILES: Mapping[str, str] = MappingProxyType(
{
"entra_obo": "entra",
"entra_app": "entra",
"rfc8693_obo": "rfc8693",
}
)
def _is_dynamic_auth_mode(mode: str) -> bool:
"""The one in-module spelling of "this mode mints at runtime".
@@ -112,6 +147,69 @@ def dynamic_auth_key_error(models: Mapping[str, ModelConfig], app_state: Any) ->
)
def profile_mismatched_aliases(
models: Mapping[str, ModelConfig], obo_grant_profile: str
) -> list[tuple[str, str, str]]:
"""Dynamic aliases whose mode's paired profile is not *obo_grant_profile*.
Pure projection for the swap/boot visibility warnings: such a row is
legal to keep (same-pair edits and re-arms pass the write validator) but
can never mint on this deployment its mint refuses with
``grant_profile_mismatch``. Returns sorted ``(alias, auth_mode,
required_profile)``. Static and unmapped modes are skipped: static never
mints, and an unmapped dynamic mode is refused at pair-choose and fails
closed at dispatch, so neither is a *profile* mismatch.
"""
rows = [
(alias, cfg.auth_mode, MODEL_AUTH_MODE_PROFILES[cfg.auth_mode])
for alias, cfg in models.items()
if cfg.auth_mode in DYNAMIC_MODEL_AUTH_MODES
and cfg.auth_mode in MODEL_AUTH_MODE_PROFILES
and MODEL_AUTH_MODE_PROFILES[cfg.auth_mode] != obo_grant_profile
]
return sorted(rows)
def warn_profile_mismatched_aliases(models: Mapping[str, ModelConfig], app_state: Any) -> None:
"""Warn for every alias whose mode can never mint on this deployment.
The one spelling of the visibility pass both swap surfaces run
:meth:`ModelRegistry.reload` and the lifespan boot so the wording and
the profile extraction cannot drift. Not a gate: such a row stays legal
to keep (same-pair edits pass the write validator), but its mint always
refuses. No-op unless OIDC is ENABLED and names a grant profile: the
runtime refuses at the enabled check first (the loaded config defaults
``obo_grant_profile`` even when OIDC is off), so a mismatch warning on a
disabled deployment would name a remedy flip the profile that cannot
make the alias mint. The named cause matches what the alias's mint
actually records at refusal: the app-identity mint refuses a non-entra
profile as ``unsupported_grant_profile``; the delegated legs refuse as
``grant_profile_mismatch`` so an operator can grep the runtime
heartbeat for exactly the token this warning names.
"""
oidc_config = getattr(app_state, "oidc_config", None)
if oidc_config is None or not getattr(oidc_config, "enabled", False):
return
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
if not profile:
return
for alias, mode, required in profile_mismatched_aliases(models, profile):
cause = (
"unsupported_grant_profile"
if mode in APP_IDENTITY_MODEL_AUTH_MODES
else "grant_profile_mismatch"
)
log.warning(
"model alias %r auth_mode %r requires obo_grant_profile=%r "
"(configured: %r) — it will not mint (cause=%s)",
alias,
mode,
required,
profile,
cause,
)
# ---------------------------------------------------------------------------
# Model configuration
# ---------------------------------------------------------------------------
@@ -144,21 +242,89 @@ class ModelConfig:
server_compat: dict[str, Any] = field(default_factory=dict)
# Backend credential mode. ``static`` (default) sends ``api_key`` unchanged;
# ``entra_obo`` mints a caller-delegated Entra token; ``entra_app`` mints a
# shared app-identity token. Dynamic tokens are bound as the SDK credential
# (x-api-key for Anthropic surfaces, Authorization: Bearer for OpenAI-style).
# ``obo_audience`` is the exact operator-approved resource App ID URI.
# shared app-identity token; ``rfc8693_obo`` mints a caller-delegated token
# via RFC 8693 token exchange. Dynamic tokens are bound as the SDK
# credential (x-api-key for Anthropic surfaces, Authorization: Bearer for
# OpenAI-style). ``obo_audience`` is the exact operator-approved resource
# identifier; ``obo_scopes`` is the space-separated scope list the
# rfc8693 exchange leg requests (inert for every other mode).
auth_mode: str = "static"
obo_audience: str = ""
obo_scopes: str = ""
def _normalize_auth_mode(alias: str, mode: Any, audience: Any) -> tuple[str, str]:
def strip_control_characters(value: str) -> str:
"""Remove every C0 (U+0000U+001F) and DEL (U+007F) character.
The ONE spelling of the control-character class every backend-auth text
surface guards the scopes sanitize below, the registry's refuse
predicate, the mint entry points' audience/alias hygiene, and the
console's OAuth text cleaner all delegate here, so a later widening of
the class (or a narrowing) lands everywhere at once instead of silently
splitting the write path's strip from the load path's refusal.
"""
return "".join(ch for ch in value if ord(ch) >= 32 and ord(ch) != 127)
def sanitize_backend_auth_scopes(value: Any) -> str:
"""The ONE spelling of the backend-auth scopes sanitize.
Collapse whitespace runs to single spaces, strip the remaining C0/DEL
control characters, then re-collapse the runs the stripping can reopen
(``"a \\x01 b"`` becomes ``"a b"`` becomes ``"a b"``). Collapse-first
ordering is load-bearing: stripping a tab-separated list first would
CONCATENATE the scopes the tab separates. No length cap and no refusal
policy (caps, and refuse-vs-strip on garbage) stays with each consuming
layer; this function only fixes the shared spelling those policies
measure, so the console store, the registry load, and the mint request
can never disagree on what a scopes value *is*.
"""
collapsed = " ".join(str(value or "").split())
return " ".join(strip_control_characters(collapsed).split())
def _check_auth_text(alias: str, field: str, value: str) -> None:
"""Refuse control characters and over-length in a backend-auth text field.
The registry's REFUSE policy, shared by the audience and scopes arms of
:func:`_normalize_auth_mode` so the two cannot drift on wording or
bounds: the write path sanitizes, this layer refuses what sanitization
would have prevented, so DB-direct garbage fails loud rather than
loading.
"""
if strip_control_characters(value) != value:
raise ModelAuthConfigError(f"Model '{alias}' {field} contains control characters")
if len(value) > MODEL_AUTH_TEXT_MAX_LEN:
raise ModelAuthConfigError(
f"Model '{alias}' {field} exceeds {MODEL_AUTH_TEXT_MAX_LEN} characters"
)
def _normalize_auth_mode(
alias: str, mode: Any, audience: Any, scopes: Any = ""
) -> tuple[str, str, str]:
"""Validate and normalize one model's backend-auth configuration.
DB and config.toml rows share this path so a typo cannot silently downgrade
dynamic authentication to a static key. Audience values remain literal:
environment expansion would make authorization node-dependent and could
bypass the admin allow-list and length boundary.
dynamic authentication to a static key. Audience and scope values remain
literal: environment expansion would make authorization node-dependent and
could bypass the admin allow-list and length boundary.
``obo_scopes`` gets shape checks only, and like the audience's shape
checks they run REGARDLESS of auth_mode: the write path sanitizes,
this layer refuses what sanitization would have prevented, so DB-direct
garbage fails loud rather than loading. What IS mode-tolerant is the
coupling: a stored value on a mode outside SCOPES_MODEL_AUTH_MODES is
accepted exactly like a stale audience on a static row the console
refuses NEW staging, an already-stored value must not make the alias
unloadable, and the dispatch never reads it, so it is inert.
"""
# The alias is the identity every mint-cache, cooldown, cause and purge
# key derives from, so it takes the same refuse-not-strip guard as the
# auth text fields: a control-bearing alias would silently collide with
# its stripped twin at the key builders (which strip controls as a
# raw-caller seam), merging two definitions onto one identity.
_check_auth_text(alias, "alias", str(alias or ""))
normalized_mode = str(mode or "static").strip() or "static"
normalized_audience = str(audience or "").strip()
if normalized_mode not in MODEL_AUTH_MODES:
@@ -170,11 +336,21 @@ def _normalize_auth_mode(alias: str, mode: Any, audience: Any) -> tuple[str, str
raise ModelAuthConfigError(
f"Model '{alias}' requires obo_audience when auth_mode is {normalized_mode!r}"
)
if any(ord(ch) < 32 or ord(ch) == 127 for ch in normalized_audience):
raise ModelAuthConfigError(f"Model '{alias}' obo_audience contains control characters")
if len(normalized_audience) > 2048:
raise ModelAuthConfigError(f"Model '{alias}' obo_audience exceeds 2048 characters")
return normalized_mode, normalized_audience
_check_auth_text(alias, "obo_audience", normalized_audience)
# The guard sees the separator-tolerated spelling, NOT the sanitized
# one: the sanctioned separators — tab/newline/CR, the config spellings
# the corpus blesses — read as collapsed spaces, while every OTHER C0
# byte stays visible for the refusal. A bare str.split() would swallow
# the C0 separator block (U+001CU+001F counts as Python whitespace)
# before the guard could see it, silently loading bytes the write path
# could never have stored (pinned:
# test_obo_scopes_normalizers_agree_across_modules). Once the guard
# passes, the spellings converge, so the returned value routes through
# the shared transform.
tolerated = re.sub(r"[ \t\n\r]+", " ", str(scopes or "")).strip()
_check_auth_text(alias, "obo_scopes", tolerated)
normalized_scopes = sanitize_backend_auth_scopes(scopes)
return normalized_mode, normalized_audience, normalized_scopes
def _api_surface_of(cfg: ModelConfig) -> str | None:
@@ -483,6 +659,10 @@ class ModelRegistry:
key_err = dynamic_auth_key_error(models, app_state)
if key_err:
raise DynamicAuthKeyError(key_err)
# Visibility, not a gate: a persisted row whose mode names the
# other grant dialect stays valid to keep, but its mint always
# refuses on this deployment — say so at every swap chokepoint.
warn_profile_mismatched_aliases(models, app_state)
_validate_registry_args(models, default, fallback, agent_model, task_model)
with self._client_lock:
# FIRST write inside the lock, deliberately BEFORE the map swap
@@ -669,12 +849,14 @@ def load_model_registry(
# pre-052 row missing these columns degrades gracefully.
row_surface_persisted_reasoning = bool(row.get("surface_persisted_reasoning", True))
row_replay_reasoning = bool(row.get("replay_reasoning_to_model", False))
# Per-user OBO auth (defaults match a pre-068 row missing the
# columns → "static", no audience → unchanged behaviour).
row_auth_mode, row_obo_audience = _normalize_auth_mode(
# Per-user OBO auth (defaults match a pre-068/pre-069 row
# missing the columns → "static", no audience/scopes →
# unchanged behaviour).
row_auth_mode, row_obo_audience, row_obo_scopes = _normalize_auth_mode(
alias,
row.get("auth_mode"),
row.get("obo_audience"),
row.get("obo_scopes"),
)
configs[alias] = ModelConfig(
alias=alias,
@@ -695,6 +877,7 @@ def load_model_registry(
server_compat=row_server_compat,
auth_mode=row_auth_mode,
obo_audience=row_obo_audience,
obo_scopes=row_obo_scopes,
)
except ModelAuthConfigError:
# Configuration errors are authoritative row content, not a
@@ -753,10 +936,11 @@ def load_model_registry(
entry_server_compat = entry_caps.pop("server_compat", {})
if not isinstance(entry_server_compat, dict):
entry_server_compat = {}
entry_auth_mode, entry_obo_audience = _normalize_auth_mode(
entry_auth_mode, entry_obo_audience, entry_obo_scopes = _normalize_auth_mode(
alias,
entry.get("auth_mode", "static"),
entry.get("obo_audience", ""),
entry.get("obo_scopes", ""),
)
configs[alias] = ModelConfig(
alias=alias,
@@ -778,6 +962,7 @@ def load_model_registry(
server_compat=entry_server_compat,
auth_mode=entry_auth_mode,
obo_audience=entry_obo_audience,
obo_scopes=entry_obo_scopes,
)
# 3. Back-compat shim: synthesize a "default" alias from CLI/auto-detected
+1 -1
View File
@@ -239,7 +239,7 @@ def load_oidc_config() -> OIDCConfig:
# mint legs resolve by exact name, so an unknown profile never mints.
log.warning(
"oidc: unknown obo_grant_profile %r (expected one of %s) — "
"oauth_obo MCP servers and entra_obo/entra_app model aliases will "
"oauth_obo MCP servers and dynamic-auth model aliases will "
"not mint until this is fixed",
obo_grant_profile,
", ".join(sorted(OBO_GRANT_PROFILES)),
+78 -22
View File
@@ -132,7 +132,10 @@ from turnstone.core.metacognition import (
task_unrenderable_message,
)
from turnstone.core.model_registry import (
APP_IDENTITY_MODEL_AUTH_MODES,
DYNAMIC_MODEL_AUTH_MODES,
MODEL_AUTH_MODE_PROFILES,
SCOPES_MODEL_AUTH_MODES,
ModelClientConstructionError,
)
from turnstone.core.model_turn import (
@@ -1474,23 +1477,41 @@ class BackendAuthUnavailableError(RuntimeError):
"""A fail-closed dynamic model credential could not be resolved."""
def _mint_refusal_cause(prefix: str, audience: str, user_id: str = "") -> str:
def _mint_refusal_cause(
prefix: str,
alias: str,
user_id: str = "",
grant_leg: str | None = None,
) -> str:
"""The mint's last recorded refusal cause, for the heartbeat lines.
mcp_oauth's cause layer is deduped to once per process, so mid-incident
the retained logs may hold none of its lines; the per-turn warnings in
``_model_backend_auth_token`` read this instead. Keyed per (prefix,
audience, user): an OBO read passes the minting user so a shared
audience cannot serve one user's cause on another user's heartbeat,
while ``model_app`` reads resolve to the shared app principal the app
mint records under. ``"unknown"`` when nothing was recorded.
``_model_backend_auth_token`` read this instead. The record lives at the
mint-cache key's per-alias granularity — an OBO read additionally passes
the minting user and the grant leg the mint was asked for, so one
alias's cause never serves on another's heartbeat while ``model_app``
reads resolve to the shared app principal the app mint records under.
``"unknown"`` when nothing was recorded.
"""
# Function-local import, matching the mint-client indirection: this
# module never imports mcp_oauth at module scope.
from turnstone.core.mcp_oauth import MODEL_APP_MINT_PRINCIPAL, model_mint_refusal_cause
from turnstone.core.mcp_oauth import (
MODEL_APP_MINT_PRINCIPAL,
model_app_cache_server,
model_mint_refusal_cause,
model_obo_cause_key,
)
key_user = user_id if prefix == "model_obo" else MODEL_APP_MINT_PRINCIPAL
return model_mint_refusal_cause(prefix, audience, key_user) or "unknown"
if prefix == "model_obo":
return (
model_mint_refusal_cause(prefix, model_obo_cause_key(alias, grant_leg), user_id)
or "unknown"
)
return (
model_mint_refusal_cause(prefix, model_app_cache_server(alias), MODEL_APP_MINT_PRINCIPAL)
or "unknown"
)
class ChatSession:
@@ -6472,8 +6493,9 @@ class ChatSession:
A dynamic alias with no static key always fails closed rather than
issuing the SDK-construction placeholder. When a real static key exists,
mint failures retain that explicit fallback unless the operator enables
``model.auth_fail_closed``. An ``entra_obo`` call with no user always
fails closed regardless of fallback policy.
``model.auth_fail_closed``. A delegated-mode call (any dynamic mode
outside ``APP_IDENTITY_MODEL_AUTH_MODES``) with no user always fails
closed regardless of fallback policy.
"""
registry = self._registry
if registry is None or not alias:
@@ -6491,7 +6513,11 @@ class ChatSession:
)
must_fail_closed = configured_fail_closed or not has_static_key
user_id = ""
if mode == "entra_obo":
if mode not in APP_IDENTITY_MODEL_AUTH_MODES:
# Delegated modes redeem the acting user's credential; membership
# is derived by complement so an unclassified future mode demands
# a user (fails closed and loud) rather than silently minting as
# the shared app identity.
user_id = (self._mcp_effective_user_id or "").strip()
if not user_id:
# ``audience=`` is load-bearing on all four warnings in this
@@ -6523,19 +6549,21 @@ class ChatSession:
f"Dynamic backend authentication unavailable for model alias {alias!r}"
)
return None
if mode == "entra_app":
if mode in APP_IDENTITY_MODEL_AUTH_MODES:
# App/managed identity via client-credentials — Turnstone's own SSO
# app reg. This is used only when the model definition explicitly
# selects entra_app; missing OBO context never switches grant modes.
# The gateway resolves it to one shared virtual account (no per-user
# attribution).
token = mcp.mint_app_token_sync(audience=cfg.obo_audience)
# app reg. Used only when the model definition explicitly selects
# an app-identity mode; missing OBO context never switches grant
# modes. Set membership, not a literal, so this dispatch and the
# no-user guard above cannot disagree about which modes carry a
# user. The gateway resolves it to one shared virtual account (no
# per-user attribution).
token = mcp.mint_app_token_sync(alias=alias, audience=cfg.obo_audience)
if not token:
log.warning(
"model_app.fallback_to_static",
alias=alias,
audience=cfg.obo_audience,
cause=_mint_refusal_cause("model_app", cfg.obo_audience),
cause=_mint_refusal_cause("model_app", alias),
has_static_key=has_static_key,
)
if must_fail_closed:
@@ -6544,8 +6572,36 @@ class ChatSession:
)
return None
return token
# entra_obo — per-user On-Behalf-Of.
token = mcp.mint_model_obo_token_sync(user_id=user_id, audience=cfg.obo_audience)
# Delegated user-context modes (entra_obo / rfc8693_obo) — per-user
# OBO. The mode pins its grant leg, and only scope-carrying modes
# forward the row's scopes, so residue on a mode that never reads
# them stays inert. ``grant_leg`` also keys the heartbeat's cause
# readback below — the record lives at the mint-cache key's
# per-alias granularity plus the leg.
mint_scopes = cfg.obo_scopes if mode in SCOPES_MODEL_AUTH_MODES else ""
grant_leg = MODEL_AUTH_MODE_PROFILES.get(mode)
if grant_leg is None:
# A delegated mode nobody registered a grant dialect for cannot
# pin a leg; minting with leg=None would run whatever leg the
# deployment profile names — the pre-dedicated-mode overload.
# Fail closed and loud, honoring the complement comment above.
log.warning(
"model_obo.unclassified_mode",
alias=alias,
auth_mode=mode,
audience=cfg.obo_audience,
)
raise BackendAuthUnavailableError(
f"Delegated backend authentication has no registered "
f"grant-profile pairing for model alias {alias!r}"
)
token = mcp.mint_model_obo_token_sync(
user_id=user_id,
alias=alias,
audience=cfg.obo_audience,
scopes=mint_scopes,
grant_leg=grant_leg,
)
if not token:
# A user IS driving but the mint yielded nothing (no captured
# credential, decrypt failure, or the AS rejected the grant). Never
@@ -6557,7 +6613,7 @@ class ChatSession:
alias=alias,
audience=cfg.obo_audience,
user_id=user_id,
cause=_mint_refusal_cause("model_obo", cfg.obo_audience, user_id),
cause=_mint_refusal_cause("model_obo", alias, user_id, grant_leg),
has_static_key=has_static_key,
)
if must_fail_closed:
+2
View File
@@ -5084,6 +5084,7 @@ class PostgreSQLBackend:
replay_reasoning_to_model: bool = False,
auth_mode: str = "static",
obo_audience: str = "",
obo_scopes: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
@@ -5108,6 +5109,7 @@ class PostgreSQLBackend:
replay_reasoning_to_model=1 if replay_reasoning_to_model else 0,
auth_mode=auth_mode,
obo_audience=obo_audience,
obo_scopes=obo_scopes,
created_by=created_by,
created=now,
updated=now,
+1
View File
@@ -2421,6 +2421,7 @@ class StorageBackend(Protocol):
replay_reasoning_to_model: bool = False,
auth_mode: str = "static",
obo_audience: str = "",
obo_scopes: str = "",
) -> None:
"""Create a model definition. No-op if definition_id already exists."""
...
+5 -3
View File
@@ -840,11 +840,13 @@ model_definitions = sa.Table(
sa.Column("reasoning_effort", sa.Text, nullable=True),
sa.Column("surface_persisted_reasoning", sa.Integer, nullable=False, server_default="1"),
sa.Column("replay_reasoning_to_model", sa.Integer, nullable=False, server_default="0"),
# Backend-gateway credential mode. "static" sends api_key, "entra_obo"
# mints a delegated-user token, and "entra_app" mints a shared app token
# for ``obo_audience`` at call time (migration 068).
# Backend-gateway credential mode. "static" sends api_key; "entra_obo" /
# "rfc8693_obo" mint a delegated-user token and "entra_app" a shared app
# token for ``obo_audience`` at call time (migration 068). ``obo_scopes``
# is the rfc8693 exchange-leg scope request (migration 069).
sa.Column("auth_mode", sa.Text, nullable=False, server_default="static"),
sa.Column("obo_audience", sa.Text, nullable=False, server_default=""),
sa.Column("obo_scopes", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
+2
View File
@@ -5238,6 +5238,7 @@ class SQLiteBackend:
replay_reasoning_to_model: bool = False,
auth_mode: str = "static",
obo_audience: str = "",
obo_scopes: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
@@ -5261,6 +5262,7 @@ class SQLiteBackend:
"replay_reasoning_to_model": (1 if replay_reasoning_to_model else 0),
"auth_mode": auth_mode,
"obo_audience": obo_audience,
"obo_scopes": obo_scopes,
"created_by": created_by,
"created": now,
"updated": now,
+1
View File
@@ -644,6 +644,7 @@ MODEL_DEFINITION_MUTABLE = frozenset(
"replay_reasoning_to_model",
"auth_mode",
"obo_audience",
"obo_scopes",
}
)
# Sentinel for ``update_model_definition``'s optional conditional-write
@@ -0,0 +1,32 @@
"""Add per-alias exchange scopes to model_definitions.
``obo_scopes`` carries the space-separated scope list the RFC 8693
token-exchange mint leg requests for an ``auth_mode='rfc8693_obo'`` alias.
The exchange-capable IdPs this profile targets refuse an audience whose
scope was not requested, and model definitions previously had no scope
source at all, so the delegated-user mint could never succeed on that
profile (issue #955). Empty default keeps every existing row untouched.
Revision ID: 069
Revises: 068
Create Date: 2026-08-03
"""
import sqlalchemy as sa
from alembic import op
revision = "069"
down_revision = "068"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"model_definitions",
sa.Column("obo_scopes", sa.Text, nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("model_definitions", "obo_scopes")
+17 -1
View File
@@ -4284,7 +4284,13 @@ def internal_model_reload(request: Request) -> JSONResponse:
def internal_model_status(request: Request) -> JSONResponse:
"""GET /v1/api/_internal/model-status — return this node's model aliases."""
"""GET /v1/api/_internal/model-status — return this node's model aliases.
Classified ``approve`` in :func:`turnstone.core.auth.required_scope`, not
the read default: the payload carries per-alias backend-auth
configuration (mode, audience, scopes) that the console serves only
behind admin permissions.
"""
registry = getattr(request.app.state, "registry", None)
if registry is None:
return JSONResponse({"models": {}})
@@ -4303,6 +4309,7 @@ def internal_model_status(request: Request) -> JSONResponse:
"reasoning_effort": cfg.reasoning_effort,
"auth_mode": cfg.auth_mode,
"obo_audience": cfg.obo_audience,
"obo_scopes": cfg.obo_scopes,
}
return JSONResponse({"models": models})
@@ -4693,6 +4700,15 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
await initialize_oidc_state(app.state)
# One boot-time visibility pass over the live registry: every dynamic
# alias whose mode names the other grant dialect gets the same
# will-not-mint warning ModelRegistry.reload emits on later swaps.
from turnstone.core.model_registry import warn_profile_mismatched_aliases
boot_registry = getattr(app.state, "registry", None)
if boot_registry is not None:
warn_profile_mismatched_aliases(boot_registry.models, app.state)
# MCP-OAuth token-at-rest encryption — fail-loud on misconfiguration
# when any mcp_servers row has auth_type='oauth_user'.
from turnstone.core.mcp_crypto import initialize_mcp_crypto_state