mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
+32
-21
@@ -137,28 +137,39 @@ always held to the strict public-address rule.
|
|||||||
### Model gateway credentials
|
### Model gateway credentials
|
||||||
|
|
||||||
The same OIDC registration can authenticate model gateways. A model definition
|
The same OIDC registration can authenticate model gateways. A model definition
|
||||||
with `auth_mode = "entra_obo"` redeems the driving user's captured credential
|
with `auth_mode = "entra_obo"` (Entra grant profile) or `auth_mode =
|
||||||
for its exact `obo_audience`; `auth_mode = "entra_app"` uses the registration's
|
"rfc8693_obo"` (RFC 8693 token-exchange profile) redeems the driving user's
|
||||||
client ID and secret with Entra client credentials. Both bind the result through
|
captured credential for its exact `obo_audience`; `auth_mode = "entra_app"`
|
||||||
the provider SDK's native credential option rather than injecting an override
|
uses the registration's client ID and secret with Entra client credentials.
|
||||||
header. The grant mode is never inferred: missing user context or a failed OBO
|
All three bind the result through the provider SDK's native credential option
|
||||||
mint cannot switch an `entra_obo` definition to client credentials.
|
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
|
Each dynamic mode pairs with the grant profile whose dialect it names:
|
||||||
user, and delegated/admin-consented permission to the audience. It works under
|
`entra_obo` and `entra_app` require `obo_grant_profile = "entra"`;
|
||||||
either grant profile, with one RFC 8693 caveat: the model mint sends **no scope
|
`rfc8693_obo` requires `obo_grant_profile = "rfc8693"`. The pairing is
|
||||||
parameter** (model definitions carry no per-row scopes, unlike MCP servers), so
|
enforced when a write chooses a `(auth_mode, obo_audience)` pair — a same-pair
|
||||||
the IdP must grant the alias's audience to the app client **by default**; on
|
edit of a row saved before the pairing rule keeps working — and at runtime a
|
||||||
Keycloak the exchange otherwise fails with "Requested audience not available"
|
mismatched legacy row refuses to mint with `cause=grant_profile_mismatch` and
|
||||||
(see issue #955 for the tracked fix). Turning `capture_user_credential` off
|
no IdP traffic. RFC 8693 client-credentials is not implemented.
|
||||||
later stops *new* captures but does not invalidate credentials already stored,
|
|
||||||
so existing users keep minting. `entra_app` requires `obo_grant_profile =
|
The delegated modes need the MCP encryption key, a credential captured for the
|
||||||
"entra"` and a confidential-client secret; RFC 8693 client-credentials is not
|
driving user, and delegated/admin-consented permission to the audience.
|
||||||
implemented. Configure the permitted resource IDs in the runtime setting
|
`rfc8693_obo` additionally carries `obo_scopes`, the space-separated scope
|
||||||
`model.auth_audience_allowlist` before saving dynamic model definitions.
|
list its exchange leg requests: exchange-capable IdPs that gate audiences
|
||||||
De-listing an audience later blocks every write that would arm or re-aim a
|
behind optional scopes refuse the exchange without it ("Requested audience not
|
||||||
definition at it, but does not stop aliases already configured from minting —
|
available"), which is why the scope-less Entra-named mode could never mint on
|
||||||
disabling the row (the `admin.models` disarm lever) is what stops minting. See
|
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
|
[Settings](settings.md#model-backend-authentication) for permissions, failure
|
||||||
policy, and lane identity rules.
|
policy, and lane identity rules.
|
||||||
|
|
||||||
|
|||||||
+41
-30
@@ -56,31 +56,36 @@ from the new model's overrides or global defaults.
|
|||||||
|
|
||||||
### Model backend authentication
|
### 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 |
|
| `auth_mode` | Identity sent to the model gateway |
|
||||||
|-------------|------------------------------------|
|
|-------------|------------------------------------|
|
||||||
| `static` | The definition's stored `api_key`. |
|
| `static` | The definition's stored `api_key`. |
|
||||||
| `entra_obo` | A caller-delegated Entra access token minted from that user's captured OIDC credential. |
|
| `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. |
|
| `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
|
admin can save one, an operator must add that literal audience to
|
||||||
`model.auth_audience_allowlist` (comma- or newline-separated). Wildcards and
|
`model.auth_audience_allowlist` (comma- or newline-separated). Wildcards and
|
||||||
base-URL host matching are intentionally unsupported, and a row whose
|
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
|
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
|
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
|
(clearing a stale value, or re-saving it unchanged, stays allowed).
|
||||||
that is (or becomes) dynamic, every change except the tuning fields — context
|
`obo_scopes` follows the same staging rule with the mode set inverted: only
|
||||||
window, temperature, max tokens, reasoning effort, and the two
|
`rfc8693_obo` reads it, so every other effective mode refuses to store a new
|
||||||
reasoning-persistence toggles — also requires `admin.mcp`; service tokens do
|
non-empty value, while clearing or re-saving one unchanged stays open. The
|
||||||
not bypass this capability-escalation gate. The one exception is
|
value itself is optional and shape-checked only — whether it satisfies the
|
||||||
de-escalation: a save whose only gated change is switching `enabled` off is a
|
IdP is decided at mint time. On a row that is (or becomes) dynamic, every
|
||||||
pure disable, needs only `admin.models`, and skips validation — a de-listed
|
change except the tuning fields — context window, temperature, max tokens,
|
||||||
audience must never block disarming its own row. The gate is deny-by-default:
|
reasoning effort, and the two reasoning-persistence toggles — also requires
|
||||||
a field counts as auth-relevant unless it is provably neutral, so re-enabling
|
`admin.mcp`; service tokens do not bypass this capability-escalation gate.
|
||||||
a disabled dynamic row, re-pointing its `base_url`, or swapping its provider
|
The one exception is de-escalation: a save whose only gated change is
|
||||||
or alias all escalate.
|
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
|
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
|
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
|
minting only once any authenticated request heals discovery. Until then calls
|
||||||
warn and follow the fail-open/fail-closed policy above.
|
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
|
Judge, output-guard, perception, utility, and sub-agent lanes inherit the
|
||||||
session's effective user for `entra_obo`. The perception memo is partitioned by
|
session's effective user for the delegated modes. The perception memo is
|
||||||
that principal as well as alias and content hash, so a result authorized as one
|
partitioned by that principal as well as alias and content hash, so a result
|
||||||
user cannot be served to another. Scheduled and wake-driven work retains the
|
authorized as one user cannot be served to another. Scheduled and wake-driven
|
||||||
workstream owner even when no user is connected. Eval and optimizer lanes are
|
work retains the workstream owner even when no user is connected. Eval and
|
||||||
registry-less development tools and therefore do not use dynamic model
|
optimizer lanes are registry-less development tools and therefore do not use
|
||||||
authentication.
|
dynamic model authentication.
|
||||||
|
|
||||||
`entra_app` is an explicit model-definition choice; Turnstone never changes a
|
`entra_app` is an explicit model-definition choice; Turnstone never changes a
|
||||||
failed or ownerless `entra_obo` call into a client-credentials grant. An
|
failed or ownerless delegated call into a client-credentials grant. A
|
||||||
`entra_obo` call with no effective user always refuses. A dynamic alias without
|
delegated-mode call with no effective user always refuses. A dynamic alias
|
||||||
a real static key also always refuses instead of issuing its SDK-construction
|
without a real static key also always refuses instead of issuing its
|
||||||
placeholder. When a real static key is explicitly configured, mint failures
|
SDK-construction placeholder. When a real static key is explicitly configured,
|
||||||
may use it by default; set `model.auth_fail_closed = true` to prohibit even that
|
mint failures may use it by default; set `model.auth_fail_closed = true` to
|
||||||
fallback. A refusal is not routed through the model fallback chain.
|
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,
|
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
|
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
|
`__app__` identity and are not user-deprovisioned; after client-credential
|
||||||
revocation, an already-minted app bearer remains usable until its recorded
|
revocation, an already-minted app bearer remains usable until its recorded
|
||||||
expiry.
|
expiry.
|
||||||
|
|
||||||
`obo_audience` is literal and capped at 2048 characters. Environment-variable
|
`obo_audience` and `obo_scopes` are literal and capped at 2048 characters
|
||||||
expansion is deliberately not applied, so the allow-list decision cannot vary
|
each. Environment-variable expansion is deliberately not applied, so the
|
||||||
by node or expand beyond the persisted boundary.
|
allow-list decision cannot vary by node or expand beyond the persisted
|
||||||
|
boundary.
|
||||||
|
|
||||||
### Responses output controls (per-model)
|
### Responses output controls (per-model)
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -452,7 +452,7 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
|||||||
enabled: true, temperature: null, max_tokens: null,
|
enabled: true, temperature: null, max_tokens: null,
|
||||||
reasoning_effort: null, surface_persisted_reasoning: true,
|
reasoning_effort: null, surface_persisted_reasoning: true,
|
||||||
replay_reasoning_to_model: false,
|
replay_reasoning_to_model: false,
|
||||||
auth_mode: "static", obo_audience: "",
|
auth_mode: "static", obo_audience: "", obo_scopes: "",
|
||||||
};
|
};
|
||||||
window.__putCount = 0;
|
window.__putCount = 0;
|
||||||
// Held under a private name too: auth.js's legacy window bridge
|
// Held under a private name too: auth.js's legacy window bridge
|
||||||
@@ -499,7 +499,12 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
|||||||
return reply({
|
return reply({
|
||||||
auth_audience_allowlist: ["api://example-gateway"],
|
auth_audience_allowlist: ["api://example-gateway"],
|
||||||
auth_grant_profile: "entra",
|
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/def1") >= 0) return reply(MODEL);
|
||||||
if (url.indexOf("/model-definitions") >= 0)
|
if (url.indexOf("/model-definitions") >= 0)
|
||||||
|
|||||||
+77
-121
@@ -17,25 +17,18 @@ Checks E1–E7 mirror the Entra harness:
|
|||||||
E6 unconsented audience C → NOT token, credential SURVIVES
|
E6 unconsented audience C → NOT token, credential SURVIVES
|
||||||
E7 cache flush → re-mint
|
E7 cache flush → re-mint
|
||||||
|
|
||||||
M1/M2 drive the MODEL-backend mint (``mint_obo_access_token``, issue #898) on
|
M1-M3 drive the MODEL-backend mint (``mint_obo_access_token``, #898/#955) on
|
||||||
the same captured credential — the path an ``auth_mode=entra_obo`` model alias
|
the same captured credential — the path an ``auth_mode=rfc8693_obo`` model
|
||||||
takes, distinct from the classified MCP path above:
|
alias takes, distinct from the classified MCP path above:
|
||||||
M1 model mint audience A → token carries A. Currently KNOWN-GAP on KC 26
|
M1 model mint audience A with the alias's exchange scopes → token carries A
|
||||||
standard token exchange (issue #955: model definitions carry no per-row
|
(the #955 fix: model definitions now carry per-row ``obo_scopes``, so
|
||||||
scopes, so the exchange leg sends none and KC refuses the audience) —
|
the exchange leg requests the audience's scope exactly as MCP rows do)
|
||||||
accepted ONLY on the exact gap signature: kc_calls == 2 AND E1
|
M2 warm re-mint serves the synthetic ``__model_obo__`` cache row —
|
||||||
VERIFIED AND the exchange-leg refusal the mint swallows (captured via
|
identity-keyed on the owning alias, audience + scopes in the row's
|
||||||
a module-logger hook) carries the IdP's documented no-scope refusal
|
own columns — with zero IdP calls
|
||||||
text. A None with any other signature — including a 2-call refusal
|
M3 an entra-leg mode (``entra_obo``) on this rfc8693 deployment refuses
|
||||||
with different IdP error text (malformed exchange request) — is a
|
BEFORE any IdP traffic, recording cause=grant_profile_mismatch — the
|
||||||
mint-path regression and FAILS the run
|
mode/profile pairing that replaced the pre-#955 overload
|
||||||
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.
|
|
||||||
|
|
||||||
Env (set by keycloak_e2e.sh):
|
Env (set by keycloak_e2e.sh):
|
||||||
KC_TOKEN_ENDPOINT, KC_ISSUER, KC_CLIENT_ID, KC_CLIENT_SECRET,
|
KC_TOKEN_ENDPOINT, KC_ISSUER, KC_CLIENT_ID, KC_CLIENT_SECRET,
|
||||||
@@ -55,16 +48,17 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from turnstone.core import mcp_oauth as mcp_oauth_module
|
|
||||||
from turnstone.core.mcp_crypto import (
|
from turnstone.core.mcp_crypto import (
|
||||||
MCPTokenCipher,
|
MCPTokenCipher,
|
||||||
MCPTokenCipherConfig,
|
MCPTokenCipherConfig,
|
||||||
MCPTokenStore,
|
MCPTokenStore,
|
||||||
)
|
)
|
||||||
from turnstone.core.mcp_oauth import (
|
from turnstone.core.mcp_oauth import (
|
||||||
MODEL_OBO_CACHE_PREFIX,
|
|
||||||
get_obo_access_token_classified,
|
get_obo_access_token_classified,
|
||||||
mint_obo_access_token,
|
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.oidc import OIDCConfig
|
||||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||||
@@ -106,39 +100,6 @@ class _CountingClient:
|
|||||||
return await self._inner.post(*args, **kwargs)
|
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:
|
def _password_login(cfg: dict[str, str]) -> str:
|
||||||
"""Headless direct-access grant → a real refresh token for the user."""
|
"""Headless direct-access grant → a real refresh token for the user."""
|
||||||
resp = httpx.post(
|
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"])
|
ok, aud = aud_carries(r.token, cfg["AUD_A"])
|
||||||
row = storage.get_mcp_user_token(USER, "kc-a")
|
row = storage.get_mcp_user_token(USER, "kc-a")
|
||||||
cache_ok = row is not None and row["refresh_token_ct"] is None
|
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(
|
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"E1 mint A (refresh→exchange): kind=token aud={aud} want={cfg['AUD_A']} "
|
||||||
f"cache_row_refreshless={cache_ok}",
|
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)",
|
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
|
# M1-M3 — MODEL backend mint on the rfc8693 profile: same captured
|
||||||
# captured credential and legs, but through mint_obo_access_token,
|
# credential and legs as E1-E7, but through mint_obo_access_token —
|
||||||
# the path an auth_mode=entra_obo alias takes. entra_obo is allowed
|
# the path an auth_mode=rfc8693_obo alias takes, carrying the
|
||||||
# under either grant profile (only entra_app is entra-only), and this
|
# per-alias exchange scopes MCP rows always had (#955). The mint's
|
||||||
# is the one place that combination runs against a real IdP.
|
# 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
|
posts_before = client.posts
|
||||||
log_hook = _MintFailureLogHook(mcp_oauth_module.log)
|
m1 = await mint_obo_access_token(
|
||||||
mcp_oauth_module.log = log_hook # type: ignore[assignment]
|
app_state=app_state,
|
||||||
try:
|
user_id=USER,
|
||||||
m1 = await mint_obo_access_token(
|
alias="model-a",
|
||||||
app_state=app_state, user_id=USER, audience=cfg["AUD_A"]
|
audience=cfg["AUD_A"],
|
||||||
)
|
scopes=cfg.get("SCOPE_A", ""),
|
||||||
finally:
|
grant_leg="rfc8693",
|
||||||
mcp_oauth_module.log = log_hook.inner
|
)
|
||||||
m1_kc_calls = client.posts - posts_before
|
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:
|
if m1:
|
||||||
m1_status = "VERIFIED" if ok1 and m1_kc_calls > 0 else "FAILED"
|
ok1, why1 = aud_carries(m1, cfg["AUD_A"])
|
||||||
record(
|
record(
|
||||||
m1_status,
|
"VERIFIED" if ok1 and m1_kc_calls > 0 else "FAILED",
|
||||||
f"M1 model mint (rfc8693): token={redact(m1)} aud_ok={ok1} ({why1}) "
|
f"M1 model mint (rfc8693_obo, scoped exchange): token={redact(m1)} "
|
||||||
f"kc_calls={m1_kc_calls} (want >=1)",
|
f"aud_ok={ok1} ({why1}) 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})",
|
|
||||||
)
|
)
|
||||||
else:
|
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(
|
record(
|
||||||
m1_status,
|
"FAILED",
|
||||||
"M1 model mint (rfc8693): no token and the failure signature "
|
f"M1 model mint (rfc8693_obo): no token (kc_calls={m1_kc_calls}) — "
|
||||||
f"does not match the #955 gap (kc_calls={m1_kc_calls}, want 2 "
|
"the #955 scope wire-through should mint here",
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# M2 — warm re-mint serves the synthetic __model_obo__ cache row with
|
# M2 — warm re-mint serves the synthetic __model_obo__ cache row —
|
||||||
# zero IdP calls, and the row is named so deprovisioning can find it.
|
# 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
|
posts_before = client.posts
|
||||||
m2 = await mint_obo_access_token(app_state=app_state, user_id=USER, audience=cfg["AUD_A"])
|
m2 = await mint_obo_access_token(
|
||||||
cache_row = storage.get_mcp_user_token(USER, f"{MODEL_OBO_CACHE_PREFIX}{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",
|
||||||
|
)
|
||||||
|
cache_row = storage.get_mcp_user_token(USER, model_obo_cache_server("model-a"))
|
||||||
if m1:
|
if m1:
|
||||||
record(
|
record(
|
||||||
"VERIFIED"
|
"VERIFIED"
|
||||||
@@ -373,12 +310,31 @@ async def _run(cfg: dict[str, str], refresh_token: str) -> None:
|
|||||||
f"{'present' if cache_row is not None else 'MISSING'}",
|
f"{'present' if cache_row is not None else 'MISSING'}",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Blocked behind M1: inherit its classification, so a FAILED M1
|
record("FAILED", "M2 model cache-hit: blocked behind M1 — M1 failed, see above")
|
||||||
# cannot launder its downstream leg into a KNOWN-GAP pass.
|
|
||||||
if m1_status == "KNOWN-GAP":
|
# M3 — the mode/profile pairing refusal that replaced the pre-#955
|
||||||
record("KNOWN-GAP", "M2 model cache-hit: blocked behind M1 — see issue #955")
|
# overload: an entra-leg mode on this rfc8693 deployment must yield
|
||||||
else:
|
# None with ZERO IdP calls and record the grant_profile_mismatch
|
||||||
record("FAILED", "M2 model cache-hit: blocked behind M1 — M1 failed, see above")
|
# 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:
|
finally:
|
||||||
await inner.aclose()
|
await inner.aclose()
|
||||||
|
|
||||||
@@ -408,7 +364,7 @@ def main() -> int:
|
|||||||
print("\n=== summary ===")
|
print("\n=== summary ===")
|
||||||
for status, msg in RESULTS:
|
for status, msg in RESULTS:
|
||||||
print(f" {status:>8} {msg}")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -10932,6 +10932,11 @@
|
|||||||
"title": "Obo Audience",
|
"title": "Obo Audience",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"obo_scopes": {
|
||||||
|
"default": "",
|
||||||
|
"title": "Obo Scopes",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"title": "Source",
|
"title": "Source",
|
||||||
@@ -11062,6 +11067,11 @@
|
|||||||
"title": "Obo Audience",
|
"title": "Obo Audience",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"obo_scopes": {
|
||||||
|
"default": "",
|
||||||
|
"title": "Obo Scopes",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"title": "Source",
|
"title": "Source",
|
||||||
@@ -11192,6 +11202,11 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"title": "Obo Audience",
|
"title": "Obo Audience",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obo_scopes": {
|
||||||
|
"default": "",
|
||||||
|
"title": "Obo Scopes",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -11377,6 +11392,18 @@
|
|||||||
],
|
],
|
||||||
"default": null,
|
"default": null,
|
||||||
"title": "Obo Audience"
|
"title": "Obo Audience"
|
||||||
|
},
|
||||||
|
"obo_scopes": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Obo Scopes"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "UpdateModelDefinitionRequest",
|
"title": "UpdateModelDefinitionRequest",
|
||||||
@@ -11416,7 +11443,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"auth_grant_profile": {
|
"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",
|
"title": "Auth Grant Profile",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -11427,12 +11454,39 @@
|
|||||||
},
|
},
|
||||||
"title": "Dynamic Auth Modes",
|
"title": "Dynamic Auth Modes",
|
||||||
"type": "array"
|
"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": [
|
"required": [
|
||||||
"auth_audience_allowlist",
|
"auth_audience_allowlist",
|
||||||
"auth_grant_profile",
|
"auth_grant_profile",
|
||||||
"dynamic_auth_modes"
|
"dynamic_auth_modes",
|
||||||
|
"scopes_auth_modes",
|
||||||
|
"app_identity_auth_modes",
|
||||||
|
"auth_mode_profiles"
|
||||||
],
|
],
|
||||||
"title": "ModelAuthConstraintsResponse",
|
"title": "ModelAuthConstraintsResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ from turnstone.console.server import (
|
|||||||
admin_update_model_definition,
|
admin_update_model_definition,
|
||||||
)
|
)
|
||||||
from turnstone.core.model_registry import (
|
from turnstone.core.model_registry import (
|
||||||
|
APP_IDENTITY_MODEL_AUTH_MODES,
|
||||||
DYNAMIC_MODEL_AUTH_MODES,
|
DYNAMIC_MODEL_AUTH_MODES,
|
||||||
|
MODEL_AUTH_MODE_PROFILES,
|
||||||
|
SCOPES_MODEL_AUTH_MODES,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
)
|
)
|
||||||
@@ -87,6 +90,7 @@ def _seed_model_def(
|
|||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
auth_mode: str = "static",
|
auth_mode: str = "static",
|
||||||
obo_audience: str = "",
|
obo_audience: str = "",
|
||||||
|
obo_scopes: str = "",
|
||||||
capabilities: str = "{}",
|
capabilities: str = "{}",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Insert a model definition row directly via the storage API."""
|
"""Insert a model definition row directly via the storage API."""
|
||||||
@@ -103,6 +107,7 @@ def _seed_model_def(
|
|||||||
created_by="admin",
|
created_by="admin",
|
||||||
auth_mode=auth_mode,
|
auth_mode=auth_mode,
|
||||||
obo_audience=obo_audience,
|
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
|
# 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).
|
# 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["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(
|
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"]
|
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,
|
storage: SQLiteBackend,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The delegated leg is a refresh-token grant and works under either
|
"""Fail-closed IN code, not by map absence: a dynamic mode nobody paired
|
||||||
profile — the pair above proves the check discriminates per mode.
|
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")
|
_seed_model_def(storage, definition_id="m1", alias="local", model="m")
|
||||||
client = _make_client(
|
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")
|
client.app.state.oidc_config = make_oidc_config(obo_grant_profile="rfc8693")
|
||||||
_stub_console_mcp(monkeypatch)
|
_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
|
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(
|
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"]
|
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:
|
def test_keyless_pure_disable_still_succeeds(storage: SQLiteBackend) -> None:
|
||||||
"""Posture-on-arming must not leak into de-escalation: the posture tier
|
"""Posture-on-arming must not leak into de-escalation: the posture tier
|
||||||
guards what a write ARMS, and a disable arms nothing.
|
guards what a write ARMS, and a disable arms nothing.
|
||||||
@@ -3034,6 +3700,7 @@ def test_model_definition_schema_auth_classification(storage: SQLiteBackend) ->
|
|||||||
"enabled",
|
"enabled",
|
||||||
"auth_mode",
|
"auth_mode",
|
||||||
"obo_audience",
|
"obo_audience",
|
||||||
|
"obo_scopes",
|
||||||
} == MODEL_DEFINITION_MUTABLE - MODEL_AUTH_NEUTRAL_FIELDS
|
} == MODEL_DEFINITION_MUTABLE - MODEL_AUTH_NEUTRAL_FIELDS
|
||||||
|
|
||||||
|
|
||||||
@@ -3067,6 +3734,7 @@ def test_every_mutable_column_probes_its_classification(
|
|||||||
"replay_reasoning_to_model": True,
|
"replay_reasoning_to_model": True,
|
||||||
"auth_mode": "entra_app",
|
"auth_mode": "entra_app",
|
||||||
"obo_audience": "api://other",
|
"obo_audience": "api://other",
|
||||||
|
"obo_scopes": "aud-gw openid",
|
||||||
}
|
}
|
||||||
assert set(probes) == set(MODEL_DEFINITION_MUTABLE), (
|
assert set(probes) == set(MODEL_DEFINITION_MUTABLE), (
|
||||||
"a mutable column has no probe value — add one so its classification "
|
"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}",
|
alias=f"gw-{column}",
|
||||||
model="m",
|
model="m",
|
||||||
enabled=(column != "enabled"),
|
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",
|
obo_audience="api://approved",
|
||||||
)
|
)
|
||||||
client = _make_client(storage, _make_registry(alias=f"gw-{column}", model="m"))
|
client = _make_client(storage, _make_registry(alias=f"gw-{column}", model="m"))
|
||||||
|
|||||||
@@ -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 "
|
"the coordinator retry sweep must remove a bar emptied of its last "
|
||||||
"control — screen readers announce empty 'Message actions' toolbars"
|
"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}"
|
||||||
|
|||||||
@@ -186,6 +186,12 @@ class TestRequiredScope:
|
|||||||
assert required_scope("POST", "/api/admin/users") == "approve"
|
assert required_scope("POST", "/api/admin/users") == "approve"
|
||||||
assert required_scope("DELETE", "/api/admin/users/abc") == "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):
|
def test_versioned_path(self):
|
||||||
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
|
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
|
||||||
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
|
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
USER = "user-1"
|
USER = "user-1"
|
||||||
AUDIENCE = "https://models.example.com"
|
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(
|
_MIGRATIONS_DIR = str(
|
||||||
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
|
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
|
||||||
@@ -150,6 +154,51 @@ class TestMigration068:
|
|||||||
engine.dispose()
|
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
|
# Storage + admin-load round-trip
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -220,6 +269,62 @@ class TestModelDefinitionStorage:
|
|||||||
with pytest.raises(ModelAuthConfigError, match="invalid auth_mode"):
|
with pytest.raises(ModelAuthConfigError, match="invalid auth_mode"):
|
||||||
load_model_registry(storage=storage, allow_empty=True)
|
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(
|
def test_runtime_audience_rejects_control_characters(
|
||||||
self,
|
self,
|
||||||
storage: SQLiteBackend,
|
storage: SQLiteBackend,
|
||||||
@@ -324,10 +429,11 @@ def _seed_credential(state: SimpleNamespace, *, refresh_token: str = "rt-1") ->
|
|||||||
|
|
||||||
|
|
||||||
def _mint(state: SimpleNamespace, **kwargs: Any) -> Any:
|
def _mint(state: SimpleNamespace, **kwargs: Any) -> Any:
|
||||||
|
kwargs.setdefault("alias", MODEL_ALIAS)
|
||||||
|
kwargs.setdefault("audience", AUDIENCE)
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
return await mint_obo_access_token(
|
return await mint_obo_access_token(app_state=state, user_id=USER, **kwargs)
|
||||||
app_state=state, user_id=USER, audience=AUDIENCE, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
return asyncio.run(_run())
|
return asyncio.run(_run())
|
||||||
|
|
||||||
@@ -386,8 +492,9 @@ class TestMintOboAccessToken:
|
|||||||
assert _mint(node_a) == "at-minted"
|
assert _mint(node_a) == "at-minted"
|
||||||
assert client.post.call_count == 1
|
assert client.post.call_count == 1
|
||||||
|
|
||||||
# Persisted as a "cache, not custody" row (refresh_token NULL), decodable.
|
# Persisted as a "cache, not custody" row (refresh_token NULL),
|
||||||
cache_server = f"__model_obo__:{AUDIENCE}"
|
# decodable, identity-keyed on the owning alias.
|
||||||
|
cache_server = f"__model_obo__:{MODEL_ALIAS}"
|
||||||
raw = storage.get_mcp_user_token(USER, cache_server)
|
raw = storage.get_mcp_user_token(USER, cache_server)
|
||||||
assert raw is not None and raw["refresh_token_ct"] is None
|
assert raw is not None and raw["refresh_token_ct"] is None
|
||||||
plain = node_a.mcp_token_store.get_user_token(USER, cache_server)
|
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_OBO_MISSING_CRED_WARNED", user_full)
|
||||||
monkeypatch.setattr(mcp_oauth_module, "_MODEL_MINT_MISCONFIG_WARNED", operator_fresh)
|
monkeypatch.setattr(mcp_oauth_module, "_MODEL_MINT_MISCONFIG_WARNED", operator_fresh)
|
||||||
mcp_oauth_module._warn_model_mint_misconfig_once(
|
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"}
|
assert operator_fresh == {"model_obo.oidc_not_enabled:api://aud"}
|
||||||
|
|
||||||
@@ -480,12 +587,14 @@ class TestMintOboAccessToken:
|
|||||||
user_fresh: set[tuple[str, str]] = set()
|
user_fresh: set[tuple[str, str]] = set()
|
||||||
monkeypatch.setattr(mcp_oauth_module, "_MODEL_MINT_MISCONFIG_WARNED", operator_full)
|
monkeypatch.setattr(mcp_oauth_module, "_MODEL_MINT_MISCONFIG_WARNED", operator_full)
|
||||||
monkeypatch.setattr(mcp_oauth_module, "_MODEL_OBO_MISSING_CRED_WARNED", user_fresh)
|
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")}
|
assert user_fresh == {("u-new", "api://aud")}
|
||||||
|
|
||||||
def test_success_clears_only_the_minting_users_cause(self, storage: SQLiteBackend) -> None:
|
def test_success_clears_only_the_minting_users_cause(self, storage: SQLiteBackend) -> None:
|
||||||
"""The last-cause record is keyed per (prefix, audience, user)."""
|
"""The last-cause record is keyed per (prefix, alias, user)."""
|
||||||
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 = MagicMock(spec=httpx.AsyncClient)
|
||||||
client.post = AsyncMock(
|
client.post = AsyncMock(
|
||||||
@@ -496,19 +605,29 @@ class TestMintOboAccessToken:
|
|||||||
state.mcp_token_store.upsert_oidc_credential("bob", ISSUER, refresh_token="rt-bob")
|
state.mcp_token_store.upsert_oidc_credential("bob", ISSUER, refresh_token="rt-bob")
|
||||||
|
|
||||||
async def _mint_as(user: str) -> Any:
|
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 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 asyncio.run(_mint_as("bob")) == "at-bob"
|
||||||
assert model_mint_refusal_cause("model_obo", AUDIENCE, "bob") == ""
|
assert (
|
||||||
assert model_mint_refusal_cause("model_obo", AUDIENCE, "alice") == "missing_credential"
|
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:
|
def test_cooldown_window_keeps_the_recorded_cause(self, storage: SQLiteBackend) -> None:
|
||||||
"""The record persists across cooldown short-circuits: only the
|
"""The record persists across cooldown short-circuits: only the
|
||||||
recording user's successful mint clears a cause."""
|
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 = MagicMock(spec=httpx.AsyncClient)
|
||||||
client.post = AsyncMock(
|
client.post = AsyncMock(
|
||||||
@@ -518,11 +637,13 @@ class TestMintOboAccessToken:
|
|||||||
state.mcp_token_store.upsert_oidc_credential("bob", ISSUER, refresh_token="rt-bob")
|
state.mcp_token_store.upsert_oidc_credential("bob", ISSUER, refresh_token="rt-bob")
|
||||||
|
|
||||||
async def _mint_as(user: str) -> Any:
|
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.
|
# First refusal records the cause and arms alice's cooldown.
|
||||||
assert asyncio.run(_mint_as("alice")) is None
|
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"
|
assert asyncio.run(_mint_as("bob")) == "at-bob"
|
||||||
posts_after_bob = client.post.call_count
|
posts_after_bob = client.post.call_count
|
||||||
|
|
||||||
@@ -530,7 +651,10 @@ class TestMintOboAccessToken:
|
|||||||
# short-circuits (no IdP traffic) and the cause survives.
|
# short-circuits (no IdP traffic) and the cause survives.
|
||||||
assert asyncio.run(_mint_as("alice")) is None
|
assert asyncio.run(_mint_as("alice")) is None
|
||||||
assert client.post.call_count == posts_after_bob
|
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:
|
def test_oidc_disabled_returns_none_no_http(self, storage: SQLiteBackend) -> None:
|
||||||
client = MagicMock(spec=httpx.AsyncClient)
|
client = MagicMock(spec=httpx.AsyncClient)
|
||||||
@@ -613,6 +737,380 @@ class TestMintOboAccessToken:
|
|||||||
assert _mint(state) is None
|
assert _mint(state) is None
|
||||||
assert client.post.call_count == 1
|
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
|
# mint_app_access_token — app-identity (client-credentials) mint
|
||||||
@@ -620,8 +1118,11 @@ class TestMintOboAccessToken:
|
|||||||
|
|
||||||
|
|
||||||
def _mint_app(state: SimpleNamespace, **kwargs: Any) -> Any:
|
def _mint_app(state: SimpleNamespace, **kwargs: Any) -> Any:
|
||||||
|
kwargs.setdefault("alias", APP_ALIAS)
|
||||||
|
kwargs.setdefault("audience", AUDIENCE)
|
||||||
|
|
||||||
async def _run() -> Any:
|
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())
|
return asyncio.run(_run())
|
||||||
|
|
||||||
@@ -646,10 +1147,11 @@ class TestMintAppAccessToken:
|
|||||||
"client_secret": "csecret",
|
"client_secret": "csecret",
|
||||||
"scope": f"{AUDIENCE}/.default",
|
"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 _mint_app(state) == "app-at"
|
||||||
assert client.post.call_count == 1
|
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)
|
raw = storage.get_mcp_user_token("__app__", cache_server)
|
||||||
assert raw is not None and raw["refresh_token_ct"] is None
|
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 _mint_app(state, force_refresh=True) == "app-2"
|
||||||
assert client.post.call_count == 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
|
# ChatSession._model_backend_auth_token — resolve at the model call site
|
||||||
@@ -745,23 +1277,97 @@ class TestModelOboToken:
|
|||||||
provider: str = "anthropic",
|
provider: str = "anthropic",
|
||||||
*,
|
*,
|
||||||
api_key: str = "static-fallback",
|
api_key: str = "static-fallback",
|
||||||
|
alias: str = "tf",
|
||||||
|
auth_mode: str = "entra_obo",
|
||||||
|
obo_audience: str = AUDIENCE,
|
||||||
|
obo_scopes: str = "",
|
||||||
) -> ModelConfig:
|
) -> ModelConfig:
|
||||||
return ModelConfig(
|
return ModelConfig(
|
||||||
alias="tf",
|
alias=alias,
|
||||||
base_url="https://gateway.example.com",
|
base_url="https://gateway.example.com",
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
model="vmg/opus",
|
model="vmg/opus",
|
||||||
provider=provider,
|
provider=provider,
|
||||||
auth_mode="entra_obo",
|
auth_mode=auth_mode,
|
||||||
obo_audience=AUDIENCE,
|
obo_audience=obo_audience,
|
||||||
|
obo_scopes=obo_scopes,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_obo_alias_with_user_returns_token(self) -> None:
|
def test_obo_alias_with_user_returns_token(self) -> None:
|
||||||
reg = _registry_with(self._obo_cfg())
|
reg = _registry_with(self._obo_cfg())
|
||||||
sess = _fake_session(registry=reg, user_id=USER, mint_token="minted-jwt")
|
sess = _fake_session(registry=reg, user_id=USER, mint_token="minted-jwt")
|
||||||
assert ChatSession._model_backend_auth_token(sess, "tf") == "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(
|
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(
|
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."""
|
"""The per-turn fallback warn names the last recorded cause inline."""
|
||||||
import logging
|
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 = MagicMock(spec=httpx.AsyncClient)
|
||||||
client.post = AsyncMock()
|
client.post = AsyncMock()
|
||||||
state = _make_app_state(
|
state = _make_app_state(
|
||||||
@@ -779,7 +1386,7 @@ class TestModelOboToken:
|
|||||||
oidc_config=make_oidc_config(obo_grant_profile="bogus"),
|
oidc_config=make_oidc_config(obo_grant_profile="bogus"),
|
||||||
)
|
)
|
||||||
_seed_credential(state)
|
_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.
|
# The decision layer: the mint client yields nothing.
|
||||||
reg = _registry_with(self._obo_cfg())
|
reg = _registry_with(self._obo_cfg())
|
||||||
@@ -810,12 +1417,19 @@ class TestModelOboToken:
|
|||||||
_seed_credential(state)
|
_seed_credential(state)
|
||||||
state.mcp_token_store = MCPTokenStore(storage, make_mcp_token_cipher(), node_id="B")
|
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
|
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.
|
# And the per-turn heartbeat renders it inline.
|
||||||
reg = _registry_with(self._obo_cfg())
|
reg = _registry_with(self._obo_cfg())
|
||||||
@@ -897,7 +1511,10 @@ class TestModelOboToken:
|
|||||||
assert intent_judge._backend_auth_resolver("tf") == "minted-jwt"
|
assert intent_judge._backend_auth_resolver("tf") == "minted-jwt"
|
||||||
session._mcp_mint_client.mint_model_obo_token_sync.assert_called_once_with(
|
session._mcp_mint_client.mint_model_obo_token_sync.assert_called_once_with(
|
||||||
user_id=USER,
|
user_id=USER,
|
||||||
|
alias="tf",
|
||||||
audience=AUDIENCE,
|
audience=AUDIENCE,
|
||||||
|
scopes="",
|
||||||
|
grant_leg="entra",
|
||||||
)
|
)
|
||||||
session._mcp_mint_client.mint_app_token_sync.assert_not_called()
|
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")
|
sess = _fake_session(registry=reg, user_id=USER, mint_token="x")
|
||||||
assert ChatSession._model_backend_auth_token(sess, "does-not-exist") is None
|
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) --------------------------
|
# -- entra_app (app-identity / client-credentials) --------------------------
|
||||||
|
|
||||||
def _app_cfg(self, *, api_key: str = "static-fallback") -> ModelConfig:
|
def _app_cfg(self, *, api_key: str = "static-fallback") -> ModelConfig:
|
||||||
@@ -1036,7 +1668,9 @@ class TestModelOboToken:
|
|||||||
reg = _registry_with(self._app_cfg())
|
reg = _registry_with(self._app_cfg())
|
||||||
sess = _fake_session(registry=reg, user_id="", mint_token=None, app_token="app-jwt")
|
sess = _fake_session(registry=reg, user_id="", mint_token=None, app_token="app-jwt")
|
||||||
assert ChatSession._model_backend_auth_token(sess, "tf") == "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()
|
sess._mcp_mint_client.mint_model_obo_token_sync.assert_not_called()
|
||||||
|
|
||||||
def test_app_alias_uses_app_identity_even_with_user(self) -> None:
|
def test_app_alias_uses_app_identity_even_with_user(self) -> None:
|
||||||
@@ -1058,3 +1692,47 @@ class TestModelOboToken:
|
|||||||
|
|
||||||
with pytest.raises(BackendAuthUnavailableError):
|
with pytest.raises(BackendAuthUnavailableError):
|
||||||
ChatSession._model_backend_auth_token(sess, "tf")
|
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
|
||||||
|
|||||||
@@ -1175,6 +1175,153 @@ class TestReloadKeyGuard:
|
|||||||
assert reg.has_dynamic_auth()
|
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
|
# Session integration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -2767,3 +2914,93 @@ class TestApplyRoutingOverrides:
|
|||||||
cs = _FakeCS(**{"model.task_alias": "nonexistent"})
|
cs = _FakeCS(**{"model.task_alias": "nonexistent"})
|
||||||
assert _apply_routing_overrides(reg, cs, _KEYED_STATE) is False
|
assert _apply_routing_overrides(reg, cs, _KEYED_STATE) is False
|
||||||
assert reg.task_model is None # unchanged
|
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 "", ""
|
||||||
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
|||||||
from turnstone.server import (
|
from turnstone.server import (
|
||||||
_collect_node_models_metadata,
|
_collect_node_models_metadata,
|
||||||
_publish_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
|
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
|
# _publish_models_metadata — cache short-circuit + projection wiring
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1016,10 +1016,12 @@ class ModelDefinitionInfo(BaseModel):
|
|||||||
reasoning_effort: str | None = None
|
reasoning_effort: str | None = None
|
||||||
surface_persisted_reasoning: bool = True
|
surface_persisted_reasoning: bool = True
|
||||||
replay_reasoning_to_model: bool = False
|
replay_reasoning_to_model: bool = False
|
||||||
# "static" (send api_key), "entra_obo" (delegated-user token), or
|
# "static" (send api_key), "entra_obo" / "rfc8693_obo" (delegated-user
|
||||||
# "entra_app" (shared app token) for obo_audience at call time.
|
# 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"
|
auth_mode: str = "static"
|
||||||
obo_audience: str = ""
|
obo_audience: str = ""
|
||||||
|
obo_scopes: str = ""
|
||||||
source: str = ""
|
source: str = ""
|
||||||
created_by: str = ""
|
created_by: str = ""
|
||||||
created: str = ""
|
created: str = ""
|
||||||
@@ -1064,6 +1066,7 @@ class CreateModelDefinitionRequest(BaseModel):
|
|||||||
replay_reasoning_to_model: bool = False
|
replay_reasoning_to_model: bool = False
|
||||||
auth_mode: str = "static"
|
auth_mode: str = "static"
|
||||||
obo_audience: str = ""
|
obo_audience: str = ""
|
||||||
|
obo_scopes: str = ""
|
||||||
|
|
||||||
|
|
||||||
class UpdateModelDefinitionRequest(BaseModel):
|
class UpdateModelDefinitionRequest(BaseModel):
|
||||||
@@ -1092,6 +1095,7 @@ class UpdateModelDefinitionRequest(BaseModel):
|
|||||||
replay_reasoning_to_model: bool | None = None
|
replay_reasoning_to_model: bool | None = None
|
||||||
auth_mode: str | None = None
|
auth_mode: str | None = None
|
||||||
obo_audience: str | None = None
|
obo_audience: str | None = None
|
||||||
|
obo_scopes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListModelDefinitionsResponse(BaseModel):
|
class ListModelDefinitionsResponse(BaseModel):
|
||||||
@@ -1125,9 +1129,10 @@ class ModelAuthConstraintsResponse(BaseModel):
|
|||||||
auth_grant_profile: str = Field(
|
auth_grant_profile: str = Field(
|
||||||
description=(
|
description=(
|
||||||
"Deployment [oidc] obo_grant_profile, or empty when single sign-on "
|
"Deployment [oidc] obo_grant_profile, or empty when single sign-on "
|
||||||
"is not configured. entra_app requires 'entra'; entra_obo works "
|
"is not configured. Each dynamic auth_mode pairs with exactly one "
|
||||||
"under either profile. A transient discovery outage reports the "
|
"profile (see auth_mode_profiles); the write validator refuses a "
|
||||||
"configured profile, not empty."
|
"new pairing that contradicts it. A transient discovery outage "
|
||||||
|
"reports the configured profile, not empty."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
dynamic_auth_modes: list[str] = Field(
|
dynamic_auth_modes: list[str] = Field(
|
||||||
@@ -1139,6 +1144,27 @@ class ModelAuthConstraintsResponse(BaseModel):
|
|||||||
"fallback only for a missing or failed constraints fetch."
|
"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):
|
class PersonaInfo(BaseModel):
|
||||||
|
|||||||
+258
-36
@@ -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.memory import get_workstream_display_names
|
||||||
from turnstone.core.metacognition import field_str, sanitize_display
|
from turnstone.core.metacognition import field_str, sanitize_display
|
||||||
from turnstone.core.model_registry import (
|
from turnstone.core.model_registry import (
|
||||||
|
APP_IDENTITY_MODEL_AUTH_MODES,
|
||||||
DYNAMIC_MODEL_AUTH_MODES,
|
DYNAMIC_MODEL_AUTH_MODES,
|
||||||
|
MODEL_AUTH_MODE_PROFILES,
|
||||||
|
MODEL_AUTH_TEXT_MAX_LEN,
|
||||||
|
SCOPES_MODEL_AUTH_MODES,
|
||||||
DynamicAuthKeyError,
|
DynamicAuthKeyError,
|
||||||
dynamic_auth_key_error,
|
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.model_registry import MODEL_AUTH_MODES as _MODEL_AUTH_MODES
|
||||||
from turnstone.core.rendezvous import NoAvailableNodeError
|
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"})
|
_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:
|
def _clean_oauth_text(value: Any, *, max_length: int = 512) -> str | None:
|
||||||
"""Normalize an admin form OAuth text field — empty string -> None.
|
"""Normalize an admin form OAuth text field — empty string -> None.
|
||||||
|
|
||||||
Caps the input to ``max_length`` characters to bound DB row size on
|
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)
|
the admin.mcp write path. Pass a larger ``max_length`` (e.g. 2048)
|
||||||
for URL fields where the default would otherwise truncate valid
|
for URL fields where the default would otherwise truncate valid long
|
||||||
long URLs; model ``obo_audience`` sites pass
|
URLs; model backend-auth sites (``obo_audience``/``obo_scopes``) pass
|
||||||
:data:`OBO_AUDIENCE_MAX_LEN`.
|
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:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
# OAuth identifiers/URLs have no valid C0 controls. Removing them here
|
# OAuth identifiers/URLs have no valid C0 controls. Removing them here
|
||||||
# prevents log/header ambiguity and benefits both MCP and model auth.
|
# (via the one shared spelling of the control class) prevents log/header
|
||||||
text = re.sub(r"[\x00-\x1f\x7f]", "", str(value)).strip()
|
# ambiguity and benefits both MCP and model auth.
|
||||||
|
text = strip_control_characters(str(value)).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
return text[:max_length]
|
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]:
|
def _parse_auth_type(body: dict[str, Any]) -> tuple[str | None, JSONResponse | None]:
|
||||||
"""Validate ``auth_type`` from a request body.
|
"""Validate ``auth_type`` from a request body.
|
||||||
|
|
||||||
@@ -11535,6 +11600,16 @@ _AUDIENCE_FORBIDS_STATIC_ERROR = (
|
|||||||
f"({'/'.join(sorted(DYNAMIC_MODEL_AUTH_MODES))}); "
|
f"({'/'.join(sorted(DYNAMIC_MODEL_AUTH_MODES))}); "
|
||||||
"omit it when auth_mode is 'static'"
|
"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:
|
def _canonical_capabilities(raw: Any) -> str | None:
|
||||||
@@ -11586,12 +11661,20 @@ class ModelAuthGateDecision:
|
|||||||
- ``enabled_armed`` implies ``not pure_disable`` — arming and
|
- ``enabled_armed`` implies ``not pure_disable`` — arming and
|
||||||
disarming are directional opposites.
|
disarming are directional opposites.
|
||||||
- ``posture_event`` is exactly ``pair_changed or enabled_armed``.
|
- ``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_auth_mode: str
|
||||||
eff_audience: str
|
eff_audience: str
|
||||||
audience_required_violation: bool
|
audience_required_violation: bool
|
||||||
static_new_audience_violation: bool
|
static_new_audience_violation: bool
|
||||||
|
scopes_staging_violation: bool
|
||||||
|
scopes_value_changed: bool
|
||||||
pair_changed: bool
|
pair_changed: bool
|
||||||
dynamic_involved: bool
|
dynamic_involved: bool
|
||||||
enabled_armed: 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_auth_mode = str(existing.get("auth_mode") or "static")
|
||||||
old_audience = (
|
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_auth_mode = str(updates.get("auth_mode", old_auth_mode))
|
||||||
eff_audience = str(updates.get("obo_audience", old_audience))
|
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 bool(updates["obo_audience"])
|
||||||
and str(updates["obo_audience"]) != old_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
|
# One derivation, two consumers: the outer gate and the validator's
|
||||||
# posture tier.
|
# posture tier.
|
||||||
pair_changed = eff_auth_mode != old_auth_mode or eff_audience != old_audience
|
pair_changed = eff_auth_mode != old_auth_mode or eff_audience != old_audience
|
||||||
@@ -11675,11 +11786,19 @@ def _derive_auth_gate(existing: dict[str, Any], updates: dict[str, Any]) -> Mode
|
|||||||
caps_changed = "capabilities" in updates and _capabilities_value_changed(
|
caps_changed = "capabilities" in updates and _capabilities_value_changed(
|
||||||
existing.get("capabilities"), updates["capabilities"]
|
existing.get("capabilities"), updates["capabilities"]
|
||||||
)
|
)
|
||||||
non_caps_gated_changed = any(
|
# obo_scopes joins the pair and capabilities in the loop's exclusion
|
||||||
key not in ("enabled", "capabilities", "auth_mode", "obo_audience")
|
# list: each excluded column has a dedicated normalization-correct
|
||||||
and key not in MODEL_AUTH_NEUTRAL_FIELDS
|
# comparator (scopes_value_changed above), and the raw-string loop
|
||||||
and str(existing.get(key) or "") != str(value or "")
|
# would misread a DB-direct stored value's collapsed re-save as a
|
||||||
for key, value in updates.items()
|
# 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
|
other_gated_changed = non_caps_gated_changed or caps_changed
|
||||||
caps_blocks_disarm = (
|
caps_blocks_disarm = (
|
||||||
@@ -11706,6 +11825,8 @@ def _derive_auth_gate(existing: dict[str, Any], updates: dict[str, Any]) -> Mode
|
|||||||
eff_audience=eff_audience,
|
eff_audience=eff_audience,
|
||||||
audience_required_violation=audience_required_violation,
|
audience_required_violation=audience_required_violation,
|
||||||
static_new_audience_violation=static_new_audience_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,
|
pair_changed=pair_changed,
|
||||||
dynamic_involved=dynamic_involved,
|
dynamic_involved=dynamic_involved,
|
||||||
enabled_armed=enabled_armed,
|
enabled_armed=enabled_armed,
|
||||||
@@ -11721,6 +11842,7 @@ def _validate_dynamic_model_auth(
|
|||||||
auth_mode: str,
|
auth_mode: str,
|
||||||
audience: str,
|
audience: str,
|
||||||
posture_event: bool = True,
|
posture_event: bool = True,
|
||||||
|
pair_changed: bool = True,
|
||||||
) -> JSONResponse | None:
|
) -> JSONResponse | None:
|
||||||
"""Validate a dynamic model-auth config at the write choke point.
|
"""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
|
request CHOOSES the ``(auth_mode, obo_audience)`` pair rather than
|
||||||
inheriting it, or RE-ARMS a disabled dynamic row (pinned:
|
inheriting it, or RE-ARMS a disabled dynamic row (pinned:
|
||||||
test_keyless_reenable_of_dynamic_row_returns_503). Checks, in sibling
|
test_keyless_reenable_of_dynamic_row_returns_503). Checks, in sibling
|
||||||
order: token store present, OIDC configured, grant profile valid and
|
order: token store present, OIDC configured, grant profile valid. A
|
||||||
able to carry the mode. A same-pair edit is never posture-blocked — an
|
same-pair edit is never posture-blocked — an existing row must not be
|
||||||
existing row must not be held hostage to posture that changed after it
|
held hostage to posture that changed after it was saved; the mint warns
|
||||||
was saved; the mint warns at runtime instead, exactly as the MCP
|
at runtime instead, exactly as the MCP contract documents (pinned:
|
||||||
contract documents (pinned:
|
|
||||||
test_base_url_edit_allowed_despite_typod_profile).
|
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
|
Every refusal names its actual cause and echoes what the operator
|
||||||
configured. A missing token store is 503 (deployment fault,
|
configured. A missing token store is 503 (deployment fault,
|
||||||
remedy-and-retry), matching the MCP sibling; config choices are 400.
|
remedy-and-retry), matching the MCP sibling; config choices are 400.
|
||||||
@@ -11823,17 +11951,53 @@ def _validate_dynamic_model_auth(
|
|||||||
},
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
if auth_mode == "entra_app" and profile != "entra":
|
if pair_changed:
|
||||||
return JSONResponse(
|
# Type-pairing: every dynamic mode names its grant dialect, so
|
||||||
{
|
# "mode matches deployment profile" is one derived rule instead of a
|
||||||
"error": (
|
# per-mode special case. Only a pair CHOICE reaches this, so legacy
|
||||||
f"auth_mode 'entra_app' requires [oidc] obo_grant_profile='entra' "
|
# rows persisted under the pre-pairing overload keep accepting
|
||||||
f"(configured: {profile!r}); RFC 8693 client-credentials is not "
|
# same-pair edits and re-arms (pinned:
|
||||||
"supported"
|
# test_base_url_edit_allowed_on_legacy_entra_obo_rfc8693_row,
|
||||||
|
# test_legacy_cross_profile_row_reenables_unchanged).
|
||||||
|
required = MODEL_AUTH_MODE_PROFILES.get(auth_mode)
|
||||||
|
if required is None:
|
||||||
|
# Fail-closed IN code, not by map absence: a dynamic mode nobody
|
||||||
|
# paired must be refused here with its remedy named — the
|
||||||
|
# registry drift test stays as the belt.
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
f"auth_mode {auth_mode!r} has no registered grant-profile "
|
||||||
|
"pairing; add it to MODEL_AUTH_MODE_PROFILES before use"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
elif profile != required:
|
||||||
|
if auth_mode in APP_IDENTITY_MODEL_AUTH_MODES:
|
||||||
|
remedy = "; RFC 8693 client-credentials is not supported"
|
||||||
|
else:
|
||||||
|
alternates = "/".join(
|
||||||
|
sorted(
|
||||||
|
mode
|
||||||
|
for mode, mode_profile in MODEL_AUTH_MODE_PROFILES.items()
|
||||||
|
if mode_profile == profile and mode not in APP_IDENTITY_MODEL_AUTH_MODES
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
remedy = (
|
||||||
status_code=400,
|
f"; delegated tokens under this profile use auth_mode {alternates!r}"
|
||||||
)
|
if alternates
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
f"auth_mode {auth_mode!r} requires [oidc] obo_grant_profile="
|
||||||
|
f"{required!r} (configured: {profile!r}){remedy}"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -12171,6 +12335,7 @@ async def admin_list_model_definitions(request: Request) -> JSONResponse:
|
|||||||
"reasoning_effort": nm.get("reasoning_effort"),
|
"reasoning_effort": nm.get("reasoning_effort"),
|
||||||
"auth_mode": nm.get("auth_mode", "static"),
|
"auth_mode": nm.get("auth_mode", "static"),
|
||||||
"obo_audience": nm.get("obo_audience", ""),
|
"obo_audience": nm.get("obo_audience", ""),
|
||||||
|
"obo_scopes": nm.get("obo_scopes", ""),
|
||||||
"source": "config",
|
"source": "config",
|
||||||
"created_by": "",
|
"created_by": "",
|
||||||
"created": "",
|
"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
|
# mirror across the language seam; the client's hand-list is only
|
||||||
# the fail-open fallback for a missing/failed fetch.
|
# the fail-open fallback for a missing/failed fetch.
|
||||||
"dynamic_auth_modes": sorted(DYNAMIC_MODEL_AUTH_MODES),
|
"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:
|
if auth_mode not in _MODEL_AUTH_MODES:
|
||||||
return JSONResponse({"error": f"Invalid auth_mode: {auth_mode!r}"}, status_code=400)
|
return JSONResponse({"error": f"Invalid auth_mode: {auth_mode!r}"}, status_code=400)
|
||||||
obo_audience = (
|
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:
|
if auth_mode in DYNAMIC_MODEL_AUTH_MODES and not obo_audience:
|
||||||
return JSONResponse({"error": _AUDIENCE_REQUIRED_ERROR}, status_code=400)
|
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.
|
# permission gate — it discriminates only on the request's own fields.
|
||||||
if auth_mode == "static" and obo_audience:
|
if auth_mode == "static" and obo_audience:
|
||||||
return JSONResponse({"error": _AUDIENCE_FORBIDS_STATIC_ERROR}, status_code=400)
|
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":
|
if auth_mode != "static":
|
||||||
# Redeeming an operator-chosen audience is the same capability as
|
# Redeeming an operator-chosen audience is the same capability as
|
||||||
# configuring oauth_audience on MCP; service credentials do not bypass
|
# 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,
|
replay_reasoning_to_model=replay_reasoning_to_model,
|
||||||
auth_mode=auth_mode,
|
auth_mode=auth_mode,
|
||||||
obo_audience=obo_audience,
|
obo_audience=obo_audience,
|
||||||
|
obo_scopes=obo_scopes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record the auth pair, as the update path already does: it is a
|
# 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":
|
if auth_mode != "static":
|
||||||
audit_detail["auth_mode"] = auth_mode
|
audit_detail["auth_mode"] = auth_mode
|
||||||
audit_detail["obo_audience"] = obo_audience
|
audit_detail["obo_audience"] = obo_audience
|
||||||
|
if obo_scopes:
|
||||||
|
audit_detail["obo_scopes"] = obo_scopes
|
||||||
record_audit(
|
record_audit(
|
||||||
storage,
|
storage,
|
||||||
audit_uid,
|
audit_uid,
|
||||||
@@ -12605,8 +12793,23 @@ async def admin_update_model_definition(request: Request) -> JSONResponse:
|
|||||||
updates["auth_mode"] = am
|
updates["auth_mode"] = am
|
||||||
if "obo_audience" in body:
|
if "obo_audience" in body:
|
||||||
updates["obo_audience"] = (
|
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
|
# Every gate fact derives purely in _derive_auth_gate, whose docstring
|
||||||
# carries the rulings; this handler only maps fields to responses.
|
# carries the rulings; this handler only maps fields to responses.
|
||||||
gate = _derive_auth_gate(existing, updates)
|
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)
|
return JSONResponse({"error": _AUDIENCE_REQUIRED_ERROR}, status_code=400)
|
||||||
if gate.static_new_audience_violation:
|
if gate.static_new_audience_violation:
|
||||||
return JSONResponse({"error": _AUDIENCE_FORBIDS_STATIC_ERROR}, status_code=400)
|
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:
|
if gate.auth_config_changed:
|
||||||
# Permission first: the validation 400s below describe deployment OIDC
|
# Permission first: the validation 400s below describe deployment OIDC
|
||||||
# posture and allow-list membership, which a caller lacking this scope
|
# 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,
|
auth_mode=gate.eff_auth_mode,
|
||||||
audience=gate.eff_audience,
|
audience=gate.eff_audience,
|
||||||
posture_event=gate.posture_event,
|
posture_event=gate.posture_event,
|
||||||
|
pair_changed=gate.pair_changed,
|
||||||
)
|
)
|
||||||
if dynamic_auth_error is not None:
|
if dynamic_auth_error is not None:
|
||||||
return dynamic_auth_error
|
return dynamic_auth_error
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
storage.update_model_definition(definition_id, **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_uid, ip = _audit_context(request)
|
||||||
audit_detail = dict(updates)
|
audit_detail = dict(updates)
|
||||||
@@ -12704,6 +12920,12 @@ async def admin_delete_model_definition(request: Request) -> JSONResponse:
|
|||||||
if existing is None:
|
if existing is None:
|
||||||
return JSONResponse({"error": "Model definition not found"}, status_code=404)
|
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)
|
storage.delete_model_definition(definition_id)
|
||||||
|
|
||||||
audit_uid, ip = _audit_context(request)
|
audit_uid, ip = _audit_context(request)
|
||||||
|
|||||||
@@ -6417,7 +6417,7 @@ let _modelAuthFetchGen = 0;
|
|||||||
// The (mode, audience) as persisted on the row being edited — empty for a
|
// 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
|
// create. Drives the hide/enable/hint state only; the server alone validates
|
||||||
// submits (a stale client copy must never block a server-valid save).
|
// 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
|
// Reranker calibration fields extracted out of the capabilities textarea in the
|
||||||
// edit modal (like server_compat), held here so they survive an unrelated edit
|
// edit modal (like server_compat), held here so they survive an unrelated edit
|
||||||
// and are re-merged on save. Reset per modal open.
|
// 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
|
// The served-data-first contract shared by the mode predicates: the FETCHED
|
||||||
// is the FETCHED constraints' `dynamic_auth_modes` (server-derived from
|
// constraints array under `constraintsKey` is authoritative when present
|
||||||
// DYNAMIC_MODEL_AUTH_MODES in turnstone/core/model_registry.py), so the
|
// and well-formed (server-derived from the classification frozensets in
|
||||||
// shelf's affordances track the server's classification by data. The
|
// turnstone/core/model_registry.py), so the shelf's affordances track the
|
||||||
// hand-list below is the FAIL-OPEN FALLBACK ONLY: constraints not yet
|
// server's classification by data. `fallbackModes` is the hand-kept
|
||||||
// fetched, fetch failed, or a server that does not send the field.
|
// FAIL-OPEN FALLBACK ONLY: constraints not yet fetched, fetch failed, or a
|
||||||
function _isDynamicAuthMode(mode) {
|
// server that does not send the field.
|
||||||
|
function _servedModeListHas(constraintsKey, fallbackModes, mode) {
|
||||||
if (
|
if (
|
||||||
_modelAuthConstraints &&
|
_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() {
|
function _modelRolesAccessible() {
|
||||||
@@ -6978,28 +7008,54 @@ function _syncModelAuthFields() {
|
|||||||
const section = document.getElementById("model-auth-section");
|
const section = document.getElementById("model-auth-section");
|
||||||
if (section) {
|
if (section) {
|
||||||
const nothingDynamic =
|
const nothingDynamic =
|
||||||
!persistedDynamicMode && !dynamic && !_modelAuthPersisted.audience;
|
!persistedDynamicMode &&
|
||||||
|
!dynamic &&
|
||||||
|
!_modelAuthPersisted.audience &&
|
||||||
|
!_modelAuthPersisted.scopes;
|
||||||
const useless =
|
const useless =
|
||||||
(known && !profile && nothingDynamic) || (!editable && nothingDynamic);
|
(known && !profile && nothingDynamic) || (!editable && nothingDynamic);
|
||||||
section.style.display = useless ? "none" : "";
|
section.style.display = useless ? "none" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// entra_app is client-credentials only — no RFC 8693 leg exists — so the
|
// Each dynamic mode pairs with exactly one grant profile (served as
|
||||||
// option greys out when the profile is AFFIRMATIVELY known to be something
|
// auth_mode_profiles), so an option greys out when the profile is
|
||||||
// else, but never for the mode the row is persisted with, or the select
|
// AFFIRMATIVELY known to be a different one — but never for the mode the
|
||||||
// would fall back and rewrite the row on save. Unknown constraints disable
|
// row is persisted with, or the select would fall back and rewrite the
|
||||||
// nothing: affordance, not gate. Option labels live in index.html.
|
// row on save. Unknown constraints disable nothing: affordance, not gate.
|
||||||
const appOpt = modeSel.querySelector('option[value="entra_app"]');
|
// 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;
|
let unavailable = false;
|
||||||
if (appOpt) {
|
for (let i = 0; i < modeSel.options.length; i++) {
|
||||||
appOpt.disabled =
|
const opt = modeSel.options[i];
|
||||||
|
const required = profileOf(opt.value);
|
||||||
|
if (!required) continue; // static and injected server-defined modes
|
||||||
|
opt.disabled =
|
||||||
known &&
|
known &&
|
||||||
!!profile &&
|
!!profile &&
|
||||||
profile !== "entra" &&
|
profile !== required &&
|
||||||
_modelAuthPersisted.mode !== "entra_app";
|
_modelAuthPersisted.mode !== opt.value;
|
||||||
unavailable = appOpt.disabled;
|
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;
|
modeSel.disabled = !editable;
|
||||||
const stored = audSel.value || "";
|
const stored = audSel.value || "";
|
||||||
|
|
||||||
@@ -7011,9 +7067,11 @@ function _syncModelAuthFields() {
|
|||||||
if (modeHint) {
|
if (modeHint) {
|
||||||
modeHint.textContent = !editable
|
modeHint.textContent = !editable
|
||||||
? "needs the MCP admin permission to change"
|
? "needs the MCP admin permission to change"
|
||||||
: unavailable
|
: persistedMismatch
|
||||||
? "app identity needs the deployment to sign in through Entra"
|
? "saved mode doesn't match this deployment's sign-in profile — it will not mint until changed"
|
||||||
: "";
|
: unavailable
|
||||||
|
? "greyed-out modes need a different sign-in profile than this deployment uses"
|
||||||
|
: "";
|
||||||
}
|
}
|
||||||
if (audHint) {
|
if (audHint) {
|
||||||
// The field is free text, so missing constraints cost suggestions, not
|
// The field is free text, so missing constraints cost suggestions, not
|
||||||
@@ -7038,6 +7096,31 @@ function _syncModelAuthFields() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scopes input: the audience's affordance rules exactly — editable when
|
||||||
|
// the selected mode reads it, or when residue lingers so it can be
|
||||||
|
// cleared. Null-guarded so a stale cached page without the input keeps
|
||||||
|
// repainting the rest of the block.
|
||||||
|
const scopesInput = document.getElementById("model-obo-scopes");
|
||||||
|
const scopesHint = document.getElementById("model-obo-scopes-hint");
|
||||||
|
if (scopesInput) {
|
||||||
|
const scopesMode = _isScopesAuthMode(mode);
|
||||||
|
const storedScopes = scopesInput.value || "";
|
||||||
|
scopesInput.disabled = !editable || (!scopesMode && !storedScopes);
|
||||||
|
if (scopesHint) {
|
||||||
|
if (!editable) {
|
||||||
|
scopesHint.textContent = "needs the MCP admin permission to change";
|
||||||
|
} else if (!scopesMode) {
|
||||||
|
// Mirrors the server's staging guard, like the audience hint above.
|
||||||
|
scopesHint.textContent = storedScopes
|
||||||
|
? "unused by this mode — clear it to drop the value; a different value would be refused"
|
||||||
|
: "only used by token-exchange modes";
|
||||||
|
} else {
|
||||||
|
scopesHint.textContent =
|
||||||
|
"space-separated; requested on the token exchange (optional)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The server treats a base-URL edit as an auth change when EITHER the
|
// 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
|
// stored or the newly selected mode is dynamic, so mirror that
|
||||||
// disjunction — keying only off the current selection would stay silent
|
// 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");
|
if (m.replay_reasoning_to_model === true) overrides.push("replay=on");
|
||||||
// Anything but the shared API key is worth showing: it changes whose
|
// Anything but the shared API key is worth showing: it changes whose
|
||||||
// identity the gateway sees. Static is the default and stays silent.
|
// identity the gateway sees. Static is the default and stays silent.
|
||||||
if (m.auth_mode === "entra_obo") overrides.push("auth=per-user");
|
// Derived from the shared mode predicates, never a hand list — a new
|
||||||
else if (m.auth_mode === "entra_app") overrides.push("auth=deployment");
|
// 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) {
|
if (overrides.length) {
|
||||||
const ovrSpan = document.createElement("span");
|
const ovrSpan = document.createElement("span");
|
||||||
ovrSpan.className = "model-overrides-hint";
|
ovrSpan.className = "model-overrides-hint";
|
||||||
@@ -7653,9 +7742,11 @@ function showCreateModelModal() {
|
|||||||
_clearInjectedAuthModeOptions(document.getElementById("model-auth-mode"));
|
_clearInjectedAuthModeOptions(document.getElementById("model-auth-mode"));
|
||||||
document.getElementById("model-auth-mode").value = "static";
|
document.getElementById("model-auth-mode").value = "static";
|
||||||
document.getElementById("model-obo-audience").value = "";
|
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)
|
// A create has no persisted row; the constraints fetch (fresh per open)
|
||||||
// supplies the suggestions and re-syncs the block when it lands.
|
// supplies the suggestions and re-syncs the block when it lands.
|
||||||
_modelAuthPersisted = { mode: "static", audience: "" };
|
_modelAuthPersisted = { mode: "static", audience: "", scopes: "" };
|
||||||
_fetchModelAuthConstraints();
|
_fetchModelAuthConstraints();
|
||||||
document.getElementById("model-detect-result").hidden = true;
|
document.getElementById("model-detect-result").hidden = true;
|
||||||
document.getElementById("model-detect-btn").disabled = false;
|
document.getElementById("model-detect-btn").disabled = false;
|
||||||
@@ -7743,6 +7834,7 @@ function showEditModelModal(definitionId) {
|
|||||||
_modelAuthPersisted = {
|
_modelAuthPersisted = {
|
||||||
mode: m.auth_mode || "static",
|
mode: m.auth_mode || "static",
|
||||||
audience: m.obo_audience || "",
|
audience: m.obo_audience || "",
|
||||||
|
scopes: m.obo_scopes || "",
|
||||||
};
|
};
|
||||||
const authModeSel = document.getElementById("model-auth-mode");
|
const authModeSel = document.getElementById("model-auth-mode");
|
||||||
// An unknown persisted mode gets its own (marked) option so the row
|
// 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.
|
// there regardless of what the suggestions contain.
|
||||||
document.getElementById("model-obo-audience").value =
|
document.getElementById("model-obo-audience").value =
|
||||||
m.obo_audience || "";
|
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:
|
// Repaint against the row's values. NOT a second constraints fetch:
|
||||||
// showCreateModelModal's reset already started one this shelf open and
|
// showCreateModelModal's reset already started one this shelf open and
|
||||||
// constraints are row-independent.
|
// constraints are row-independent.
|
||||||
@@ -8053,16 +8147,19 @@ function submitCreateModel() {
|
|||||||
"model-replay-reasoning",
|
"model-replay-reasoning",
|
||||||
).checked;
|
).checked;
|
||||||
|
|
||||||
// Backend auth: entra_obo mints a per-user OBO token for obo_audience at
|
// Backend auth: entra_obo / rfc8693_obo mint a per-user OBO token for
|
||||||
// call time; entra_app mints an app-identity (client-credentials) token from
|
// obo_audience at call time (the latter also requests obo_scopes on the
|
||||||
// Turnstone's SSO app reg. (The server re-validates the same pairing.)
|
// exchange); entra_app mints an app-identity (client-credentials) token
|
||||||
// The || "static" default covers only a blank CREATE form: an edit-load
|
// from Turnstone's SSO app reg. (The server re-validates the same
|
||||||
// injects an option for any server-defined mode (see
|
// 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.
|
// _ensureAuthModeOption), so edits always carry a real value.
|
||||||
const authMode = document.getElementById("model-auth-mode").value || "static";
|
const authMode = document.getElementById("model-auth-mode").value || "static";
|
||||||
const oboAudience = document
|
const oboAudience = document
|
||||||
.getElementById("model-obo-audience")
|
.getElementById("model-obo-audience")
|
||||||
.value.trim();
|
.value.trim();
|
||||||
|
const scopesEl = document.getElementById("model-obo-scopes");
|
||||||
|
const oboScopes = scopesEl ? scopesEl.value.trim() : "";
|
||||||
const authDynamic = _isDynamicAuthMode(authMode);
|
const authDynamic = _isDynamicAuthMode(authMode);
|
||||||
if (authDynamic && oboAudience === "") {
|
if (authDynamic && oboAudience === "") {
|
||||||
_showModelError("Enter a gateway audience for this auth mode");
|
_showModelError("Enter a gateway audience for this auth mode");
|
||||||
@@ -8071,7 +8168,7 @@ function submitCreateModel() {
|
|||||||
const editId = document.getElementById("model-edit-id").value;
|
const editId = document.getElementById("model-edit-id").value;
|
||||||
Object.assign(
|
Object.assign(
|
||||||
form,
|
form,
|
||||||
_authSubmitFields(authMode, oboAudience, !!editId, authDynamic),
|
_authSubmitFields(authMode, oboAudience, oboScopes, !!editId, !!scopesEl),
|
||||||
);
|
);
|
||||||
|
|
||||||
const apiKey = document.getElementById("model-api-key").value;
|
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
|
// Auth fields for a shelf submit. A static CREATE OMITS the audience key
|
||||||
// key entirely: the server refuses ANY non-empty audience there (no stored
|
// entirely: the server refuses ANY non-empty audience there (no stored
|
||||||
// row's value needs preserving), so sending leftovers a mode round-trip
|
// row's value needs preserving), so sending leftovers a mode round-trip
|
||||||
// parked in the input would manufacture an avoidable 400. EDIT always sends
|
// parked in the input would manufacture an avoidable 400. Scopes get the
|
||||||
// the pair — stored-residue semantics are the server's call.
|
// same treatment on a CREATE whose mode never reads them — and ride ONLY
|
||||||
function _authSubmitFields(authMode, oboAudience, isEdit, authDynamic) {
|
// 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 };
|
const fields = { auth_mode: authMode };
|
||||||
if (isEdit || authDynamic) {
|
if (isEdit || _isDynamicAuthMode(authMode)) {
|
||||||
fields.obo_audience = oboAudience;
|
fields.obo_audience = oboAudience;
|
||||||
}
|
}
|
||||||
|
if (hasScopesInput && (isEdit || _isScopesAuthMode(authMode))) {
|
||||||
|
fields.obo_scopes = oboScopes;
|
||||||
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1716,6 +1716,9 @@
|
|||||||
<option value="entra_app">
|
<option value="entra_app">
|
||||||
This deployment (entra_app)
|
This deployment (entra_app)
|
||||||
</option>
|
</option>
|
||||||
|
<option value="rfc8693_obo">
|
||||||
|
Each user, via token exchange (rfc8693_obo)
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1739,6 +1742,19 @@
|
|||||||
<datalist id="model-obo-audience-options"></datalist>
|
<datalist id="model-obo-audience-options"></datalist>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="model-enabled" checked />
|
<input type="checkbox" id="model-enabled" checked />
|
||||||
|
|||||||
@@ -1053,6 +1053,15 @@ def required_scope(method: str, path: str) -> str:
|
|||||||
):
|
):
|
||||||
return "approve"
|
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
|
# Write endpoints
|
||||||
if method == "POST" and normalized in WRITE_PATHS:
|
if method == "POST" and normalized in WRITE_PATHS:
|
||||||
return "write"
|
return "write"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from turnstone.core.mcp_http_parsers import (
|
|||||||
parse_www_authenticate_scope,
|
parse_www_authenticate_scope,
|
||||||
)
|
)
|
||||||
from turnstone.core.mcp_oauth import (
|
from turnstone.core.mcp_oauth import (
|
||||||
|
MintDispatchContractError,
|
||||||
TokenLookupResult,
|
TokenLookupResult,
|
||||||
emit_oauth_failure_audit,
|
emit_oauth_failure_audit,
|
||||||
get_obo_access_token_classified,
|
get_obo_access_token_classified,
|
||||||
@@ -1019,23 +1020,41 @@ class MCPClientManager:
|
|||||||
app_state.obo_http_client = self._model_auth_http_client
|
app_state.obo_http_client = self._model_auth_http_client
|
||||||
|
|
||||||
def mint_model_obo_token_sync(
|
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:
|
) -> str | None:
|
||||||
"""Resolve a per-user model-provider OBO access token synchronously.
|
"""Resolve a per-user model-provider OBO access token synchronously.
|
||||||
|
|
||||||
Bridges the sync agent/model loop to :func:`mint_obo_access_token` on
|
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),
|
the mcp-loop (same thread that owns the token store's asyncio locks),
|
||||||
mirroring ``call_tool_sync``. Returns ``None`` on any failure — no
|
mirroring ``call_tool_sync``. ``alias`` is the owning model
|
||||||
credential, mint rejected, OAuth unwired, timeout — so the model call
|
definition — the mint's cache and cause records key on it — while
|
||||||
falls back to the backend's static credential.
|
``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
|
return None
|
||||||
loop = self._loop
|
loop = self._loop
|
||||||
if loop is None or self._app_state is None:
|
if loop is None or self._app_state is None:
|
||||||
return None
|
return None
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
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,
|
loop,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -1043,45 +1062,70 @@ class MCPClientManager:
|
|||||||
except concurrent.futures.TimeoutError:
|
except concurrent.futures.TimeoutError:
|
||||||
future.cancel()
|
future.cancel()
|
||||||
log.warning(
|
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,
|
user_id,
|
||||||
|
alias,
|
||||||
audience,
|
audience,
|
||||||
)
|
)
|
||||||
return None
|
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:
|
except Exception:
|
||||||
log.debug(
|
log.debug(
|
||||||
"model obo token mint failed user=%s audience=%s",
|
"model obo token mint failed user=%s alias=%s audience=%s",
|
||||||
user_id,
|
user_id,
|
||||||
|
alias,
|
||||||
audience,
|
audience,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return None
|
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.
|
"""Resolve an app-identity (client-credentials) model token synchronously.
|
||||||
|
|
||||||
The ``auth_mode='entra_app'`` sibling of ``mint_model_obo_token_sync``:
|
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
|
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``
|
— Turnstone's own SSO app registration is the identity; ``alias`` is
|
||||||
on any failure so the model call falls back to the static credential.
|
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
|
return None
|
||||||
loop = self._loop
|
loop = self._loop
|
||||||
if loop is None or self._app_state is None:
|
if loop is None or self._app_state is None:
|
||||||
return None
|
return None
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
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,
|
loop,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return future.result(timeout=timeout)
|
return future.result(timeout=timeout)
|
||||||
except concurrent.futures.TimeoutError:
|
except concurrent.futures.TimeoutError:
|
||||||
future.cancel()
|
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
|
return None
|
||||||
except Exception:
|
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
|
return None
|
||||||
|
|
||||||
def invalidate_model_mint_memo_sync(
|
def invalidate_model_mint_memo_sync(
|
||||||
|
|||||||
+375
-99
@@ -48,6 +48,11 @@ from turnstone.core.mcp_http_parsers import (
|
|||||||
is_valid_scope_token,
|
is_valid_scope_token,
|
||||||
parse_www_authenticate_bearer,
|
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 (
|
from turnstone.core.oauth_ssrf import (
|
||||||
OAuthSSRFError,
|
OAuthSSRFError,
|
||||||
sanitize_log_text,
|
sanitize_log_text,
|
||||||
@@ -2762,45 +2767,66 @@ def _warn_dedup_once(warned: set[_DedupKey], key: _DedupKey, event: str, **field
|
|||||||
log.warning(event, **fields)
|
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
|
# 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
|
# DECISION layer (the session's per-turn heartbeat) name the cause inline on
|
||||||
# every occurrence without re-amplifying the deduped warning. The key
|
# every occurrence without re-amplifying the deduped warning. The cause key
|
||||||
# carries the USER: on a shared audience, one user's successful mint must
|
# is the alias-keyed cache key (plus the pinned leg for OBO mints), so two
|
||||||
# not clear another's recorded cause, nor stamp its own into another's
|
# aliases can never cross-stamp. The key carries the USER: one user's
|
||||||
# heartbeat — app-identity mints record under the shared
|
# 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
|
# MODEL_APP_MINT_PRINCIPAL. Written at every refusal diagnosis even when the
|
||||||
# warning was dedup-suppressed; cleared only by the recording user's
|
# warning was dedup-suppressed; cleared only by the recording user's
|
||||||
# successful mint. Capped like the per-user warn set. Tuple keys — user ids
|
# successful mint. Tuple keys — user ids and cache keys can both contain
|
||||||
# and api:// audiences can both contain ``:``.
|
# ``:``.
|
||||||
_MODEL_MINT_LAST_CAUSE: dict[tuple[str, str, str], str] = {}
|
_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)
|
def _record_mint_refusal_cause(prefix: str, cause_key: str, user_id: str, cause: str) -> None:
|
||||||
if key not in _MODEL_MINT_LAST_CAUSE and len(_MODEL_MINT_LAST_CAUSE) >= _WARN_DEDUP_CAP:
|
# ``cause_key`` matches the reader's contract: the alias-keyed cache key
|
||||||
return
|
# 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
|
_MODEL_MINT_LAST_CAUSE[key] = cause
|
||||||
|
|
||||||
|
|
||||||
# Cooldown short-circuits need no re-stamp: the cause persists until its user's mint succeeds.
|
# 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:
|
def _clear_mint_refusal_cause(prefix: str, cause_key: str, user_id: str) -> None:
|
||||||
_MODEL_MINT_LAST_CAUSE.pop((prefix, audience, user_id), 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:
|
def model_mint_refusal_cause(prefix: str, cause_key: str, user_id: str) -> str:
|
||||||
"""Best-effort cause of the most recent refused mint for *audience*.
|
"""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
|
minting principal the cause was recorded under — the acting user for
|
||||||
OBO mints, :data:`MODEL_APP_MINT_PRINCIPAL` for app-identity mints.
|
OBO mints, :data:`MODEL_APP_MINT_PRINCIPAL` for app-identity mints.
|
||||||
Returns ``""`` when no refusal has been recorded in this process for
|
Returns ``""`` when no refusal has been recorded in this process for
|
||||||
that principal (or their mint has succeeded since); callers render that
|
that principal (or their mint has succeeded since); callers render that
|
||||||
as unknown.
|
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:
|
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()
|
_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,
|
# Record the cause FIRST, unconditionally: the warning below is deduped,
|
||||||
# but the heartbeat's readback must reflect every occurrence, attributed
|
# 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(".")
|
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(
|
_warn_dedup_once(
|
||||||
_MODEL_MINT_MISCONFIG_WARNED, f"{event}:{audience}", event, audience=audience, **fields
|
_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).
|
"""Name the missing-credential cause once per (user, audience).
|
||||||
|
|
||||||
``user_id`` deliberately stays IN the log line: this is an auth event,
|
``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,
|
and remediation — link THIS user's OIDC sign-in — needs the principal,
|
||||||
the same practice as the audit rows, which carry ids.
|
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(
|
_warn_dedup_once(
|
||||||
_MODEL_OBO_MISSING_CRED_WARNED,
|
_MODEL_OBO_MISSING_CRED_WARNED,
|
||||||
(user_id, audience),
|
(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).
|
"""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
|
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.
|
not-enabled dedup slot on a transient.
|
||||||
"""
|
"""
|
||||||
if getattr(oidc_config, "discovery_retryable", False):
|
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:
|
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(
|
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:
|
) -> None:
|
||||||
"""Name the missing token-store/storage cause, once per (cause, audience).
|
"""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",
|
f"{prefix}.token_store_unavailable",
|
||||||
audience,
|
audience,
|
||||||
user_id,
|
user_id,
|
||||||
|
cause_key=cause_key,
|
||||||
has_token_store=token_store is not None,
|
has_token_store=token_store is not None,
|
||||||
has_storage=storage 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)
|
asyncio.get_running_loop().call_soon(_drop_if_idle)
|
||||||
|
|
||||||
|
|
||||||
def _model_obo_cache_server(audience: str) -> str:
|
# The unit separator joins the structural axes of synthetic key spellings:
|
||||||
"""Synthetic ``mcp_user_tokens`` server key for a model audience's mint-cache row.
|
# 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
|
# Byte bound for the synthetic mint-cache keys: ``mcp_user_tokens.server_name``
|
||||||
audience is embedded in the key — mirroring the in-process ``(user, audience)``
|
# is half the table's PRIMARY KEY and btree-indexed, and PostgreSQL's index
|
||||||
key and the ``__model_obo__:<audience>`` single-flight lock. The ``__model_obo__:``
|
# tuple limit is ~2704 bytes — a key at most 2600 UTF-8 bytes stays safely
|
||||||
prefix keeps these cache rows distinguishable from real oauth_user server rows.
|
# 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(
|
async def mint_obo_access_token(
|
||||||
*,
|
*,
|
||||||
app_state: Any,
|
app_state: Any,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
alias: str,
|
||||||
audience: str,
|
audience: str,
|
||||||
|
scopes: str = "",
|
||||||
|
grant_leg: str | None = None,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> str | None:
|
) -> 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``)
|
Redeems the user's captured refresh credential (``oidc_user_credentials``)
|
||||||
for *audience* via the configured OBO grant profile, persists any rotated
|
for *audience* via the configured OBO grant profile, persists any rotated
|
||||||
refresh token (value CAS, cluster-locked exactly like the MCP mint), and
|
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
|
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
|
"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
|
Returns ``None`` — the signal for callers to fall back to their static
|
||||||
credential — when OIDC is disabled/unconfigured, the profile has no mint
|
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
|
leg or contradicts ``grant_leg``, the user has no captured credential (or
|
||||||
is rejected. This is the model-provider entry point; the MCP-server path
|
it won't decrypt), or the mint is rejected. This is the model-provider
|
||||||
uses :func:`get_obo_access_token_classified`, which additionally owns
|
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
|
per-server cache rows, dead-grant classification, and consent affordances
|
||||||
this helper deliberately omits.
|
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
|
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
|
# 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
|
# turn but names no cause; these branches are the CAUSE layer, deduped to
|
||||||
# once per (cause, audience) per process.
|
# once per (cause, audience) per process.
|
||||||
oidc_config = getattr(app_state, "oidc_config", None)
|
oidc_config = getattr(app_state, "oidc_config", None)
|
||||||
if oidc_config is None or not getattr(oidc_config, "enabled", False):
|
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
|
return None
|
||||||
token_store: MCPTokenStore | None = getattr(app_state, "mcp_token_store", None)
|
token_store: MCPTokenStore | None = getattr(app_state, "mcp_token_store", None)
|
||||||
storage = _get_storage(app_state)
|
storage = _get_storage(app_state)
|
||||||
if token_store is None or storage is None:
|
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
|
return None
|
||||||
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
|
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
|
||||||
mint = _OBO_MINT_LEGS.get(profile)
|
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
|
# Deployment-stable, per-call-per-lane — same dedup rationale as the
|
||||||
# sibling causes.
|
# sibling causes.
|
||||||
_warn_model_mint_misconfig_once(
|
_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
|
return None
|
||||||
issuer = str(getattr(oidc_config, "issuer", "") or "")
|
issuer = str(getattr(oidc_config, "issuer", "") or "")
|
||||||
cache_server = _model_obo_cache_server(audience)
|
|
||||||
cached_token = await _serve_fresh_mint_cache(
|
cached_token = await _serve_fresh_mint_cache(
|
||||||
app_state=app_state,
|
app_state=app_state,
|
||||||
token_store=token_store,
|
token_store=token_store,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
cache_server=cache_server,
|
cache_server=cache_server,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
scopes="",
|
scopes=scopes,
|
||||||
)
|
)
|
||||||
if cached_token and not force_refresh:
|
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
|
return cached_token
|
||||||
# The model cache key is intentionally the cooldown key. The shared
|
if not cached_token and _refresh_in_cooldown(app_state, user_id, cooldown_key):
|
||||||
# ``__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):
|
|
||||||
return None
|
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
|
# coalescing, then the SAME per-(user, issuer) credential lock + cluster
|
||||||
# advisory lock the MCP mint takes — the refresh credential is the shared
|
# 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
|
# 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,
|
user_id=user_id,
|
||||||
cache_server=cache_server,
|
cache_server=cache_server,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
scopes="",
|
scopes=scopes,
|
||||||
)
|
)
|
||||||
if cached_token and not force_refresh:
|
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
|
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
|
return None
|
||||||
|
|
||||||
credential = await _read_obo_credential(
|
credential = await _read_obo_credential(
|
||||||
@@ -3126,7 +3366,7 @@ async def mint_obo_access_token(
|
|||||||
# sign-in, so no captured credential to redeem. Deduped
|
# sign-in, so no captured credential to redeem. Deduped
|
||||||
# in its OWN namespace so a large user population cannot
|
# in its OWN namespace so a large user population cannot
|
||||||
# saturate the operator-config cause set.
|
# 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":
|
elif credential.kind == "decrypt_failure":
|
||||||
# The credential exists but decrypts under no active key
|
# The credential exists but decrypts under no active key
|
||||||
# (the keyring rotated away from it). Recording the cause
|
# (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
|
# keeps the heartbeat from rendering this class as
|
||||||
# unknown, as every sibling refusal exit does.
|
# unknown, as every sibling refusal exit does.
|
||||||
_record_mint_refusal_cause(
|
_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
|
return None
|
||||||
|
|
||||||
async def _persist_rotation(new_credential_rt: str) -> None:
|
async def _persist_rotation(new_credential_rt: str) -> None:
|
||||||
@@ -3165,16 +3405,17 @@ async def mint_obo_access_token(
|
|||||||
oidc_config=oidc_config,
|
oidc_config=oidc_config,
|
||||||
credential_refresh_token=credential["refresh_token"],
|
credential_refresh_token=credential["refresh_token"],
|
||||||
audience=audience,
|
audience=audience,
|
||||||
scopes="",
|
scopes=scopes,
|
||||||
http_client=mint_client,
|
http_client=mint_client,
|
||||||
persist_rotation=_persist_rotation,
|
persist_rotation=_persist_rotation,
|
||||||
)
|
)
|
||||||
except MCPOAuthRefreshFailed:
|
except MCPOAuthRefreshFailed:
|
||||||
_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_failed")
|
_record_mint_refusal_cause("model_obo", cause_key, user_id, "mint_failed")
|
||||||
log.warning(
|
log.warning(
|
||||||
"model_obo.mint_failed",
|
"model_obo.mint_failed",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
alias=alias,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
@@ -3182,9 +3423,9 @@ async def mint_obo_access_token(
|
|||||||
|
|
||||||
access_token = tokens.get("access_token")
|
access_token = tokens.get("access_token")
|
||||||
if not isinstance(access_token, str) or not 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(
|
_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(
|
log.warning(
|
||||||
"model_obo.mint_missing_access_token",
|
"model_obo.mint_missing_access_token",
|
||||||
@@ -3202,7 +3443,7 @@ async def mint_obo_access_token(
|
|||||||
cache_server,
|
cache_server,
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
scopes="",
|
scopes=scopes,
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
)
|
)
|
||||||
@@ -3219,15 +3460,16 @@ async def mint_obo_access_token(
|
|||||||
cache_server=cache_server,
|
cache_server=cache_server,
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
scopes="",
|
scopes=scopes,
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
)
|
)
|
||||||
_clear_refresh_backoff(app_state, user_id, cache_server)
|
_clear_refresh_backoff(app_state, user_id, cooldown_key)
|
||||||
_clear_mint_refusal_cause("model_obo", audience, user_id)
|
_clear_mint_refusal_cause("model_obo", cause_key, user_id)
|
||||||
log.info(
|
log.info(
|
||||||
"model_obo.minted",
|
"model_obo.minted",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
alias=alias,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
cache_server=cache_server,
|
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
|
# 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
|
# 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
|
# 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
|
# rotation — one token per owning definition, shared by everyone, so a gateway
|
||||||
# to a single machine (virtual-account) identity with no per-user attribution. It
|
# resolves it to a single machine (virtual-account) identity with no per-user
|
||||||
# reuses the same DB mint-cache under a synthetic ``__app__`` user.
|
# attribution. It reuses the same DB mint-cache under a synthetic ``__app__``
|
||||||
|
# user.
|
||||||
|
|
||||||
_APP_CACHE_USER = MODEL_APP_MINT_PRINCIPAL
|
_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.
|
"""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 tokens carry no user, so they cache once per owning definition under
|
||||||
``__app__`` pseudo-user; the ``__model_app__:`` prefix keeps them distinct
|
the shared ``__app__`` pseudo-user; the ``__model_app__:`` prefix keeps
|
||||||
from per-user OBO rows (``__model_obo__:``) and real oauth_user server rows.
|
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(
|
async def mint_app_access_token(
|
||||||
*,
|
*,
|
||||||
app_state: Any,
|
app_state: Any,
|
||||||
|
alias: str,
|
||||||
audience: str,
|
audience: str,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -3269,9 +3519,12 @@ async def mint_app_access_token(
|
|||||||
|
|
||||||
Uses Turnstone's own SSO app registration (``[oidc]`` ``client_id`` +
|
Uses Turnstone's own SSO app registration (``[oidc]`` ``client_id`` +
|
||||||
``client_secret``) — no user, no captured refresh token, no rotation. One
|
``client_secret``) — no user, no captured refresh token, no rotation. One
|
||||||
token per audience, shared by every caller and cached in an
|
token per owning definition *alias*, shared by every caller and cached in
|
||||||
``mcp_user_tokens`` row under the synthetic ``__app__`` user until shortly
|
an ``mcp_user_tokens`` row under the synthetic ``__app__`` user until
|
||||||
before expiry. This is the ``auth_mode='entra_app'`` backend entry point —
|
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
|
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
|
identity" path (a gateway resolves it to one virtual account, no per-user
|
||||||
attribution). Because it needs no user context it also serves utility /
|
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
|
signal to fall back to the static credential — when OIDC is
|
||||||
disabled/unconfigured, the app has no secret, or the grant is rejected.
|
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
|
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
|
# The CAUSE layer, as in mint_obo_access_token: same
|
||||||
# once-per-(cause, audience) dedup.
|
# once-per-(cause, audience) dedup.
|
||||||
oidc_config = getattr(app_state, "oidc_config", None)
|
oidc_config = getattr(app_state, "oidc_config", None)
|
||||||
if oidc_config is None or not getattr(oidc_config, "enabled", False):
|
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
|
return None
|
||||||
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
|
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
|
||||||
if profile != "entra":
|
if profile != "entra":
|
||||||
_warn_model_mint_misconfig_once(
|
_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
|
return None
|
||||||
client_id = str(getattr(oidc_config, "client_id", "") or "")
|
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)
|
token_store: MCPTokenStore | None = getattr(app_state, "mcp_token_store", None)
|
||||||
storage = _get_storage(app_state)
|
storage = _get_storage(app_state)
|
||||||
if token_store is None or storage is None:
|
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
|
return None
|
||||||
issuer = str(getattr(oidc_config, "issuer", "") or "")
|
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(
|
cached_token = await _serve_fresh_mint_cache(
|
||||||
app_state=app_state,
|
app_state=app_state,
|
||||||
token_store=token_store,
|
token_store=token_store,
|
||||||
@@ -3313,17 +3588,18 @@ async def mint_app_access_token(
|
|||||||
scopes="",
|
scopes="",
|
||||||
)
|
)
|
||||||
if cached_token and not force_refresh:
|
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
|
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
|
return None
|
||||||
if not (client_id and client_secret and token_endpoint):
|
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(
|
_record_mint_refusal_cause(
|
||||||
"model_app", audience, _APP_CACHE_USER, "credentials_unavailable"
|
"model_app", cause_key, _APP_CACHE_USER, "credentials_unavailable"
|
||||||
)
|
)
|
||||||
log.warning(
|
log.warning(
|
||||||
"model_app.credentials_unavailable",
|
"model_app.credentials_unavailable",
|
||||||
|
alias=alias,
|
||||||
has_client_id=bool(client_id),
|
has_client_id=bool(client_id),
|
||||||
has_client_secret=bool(client_secret),
|
has_client_secret=bool(client_secret),
|
||||||
has_token_endpoint=bool(token_endpoint),
|
has_token_endpoint=bool(token_endpoint),
|
||||||
@@ -3331,7 +3607,7 @@ async def mint_app_access_token(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Single-flight the mint. No per-user credential to rotate, so only the
|
# 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)
|
lock = _refresh_lock_for(app_state, _APP_CACHE_USER, cache_server)
|
||||||
pg_lock = await _acquire_pg_refresh_lock(storage, _APP_CACHE_USER, cache_server)
|
pg_lock = await _acquire_pg_refresh_lock(storage, _APP_CACHE_USER, cache_server)
|
||||||
try:
|
try:
|
||||||
@@ -3345,9 +3621,9 @@ async def mint_app_access_token(
|
|||||||
scopes="",
|
scopes="",
|
||||||
)
|
)
|
||||||
if cached_token and not force_refresh:
|
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
|
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
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3364,18 +3640,18 @@ async def mint_app_access_token(
|
|||||||
leg="client-credentials",
|
leg="client-credentials",
|
||||||
)
|
)
|
||||||
except MCPOAuthRefreshFailed:
|
except MCPOAuthRefreshFailed:
|
||||||
_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_failed")
|
_record_mint_refusal_cause("model_app", cause_key, _APP_CACHE_USER, "mint_failed")
|
||||||
log.warning("model_app.mint_failed", audience=audience, exc_info=True)
|
log.warning("model_app.mint_failed", alias=alias, audience=audience, exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
access_token = tokens.get("access_token")
|
access_token = tokens.get("access_token")
|
||||||
if not isinstance(access_token, str) or not 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(
|
_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
|
return None
|
||||||
expires_at = _expires_at_from_response(
|
expires_at = _expires_at_from_response(
|
||||||
tokens, default_ttl_seconds=_OBO_DEFAULT_TTL_SECONDS
|
tokens, default_ttl_seconds=_OBO_DEFAULT_TTL_SECONDS
|
||||||
@@ -3403,9 +3679,9 @@ async def mint_app_access_token(
|
|||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
audience=audience,
|
audience=audience,
|
||||||
)
|
)
|
||||||
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cache_server)
|
_clear_refresh_backoff(app_state, _APP_CACHE_USER, cooldown_key)
|
||||||
_clear_mint_refusal_cause("model_app", audience, _APP_CACHE_USER)
|
_clear_mint_refusal_cause("model_app", cause_key, _APP_CACHE_USER)
|
||||||
log.info("model_app.minted", audience=audience, cache_server=cache_server)
|
log.info("model_app.minted", alias=alias, audience=audience, cache_server=cache_server)
|
||||||
return access_token
|
return access_token
|
||||||
finally:
|
finally:
|
||||||
_prune_model_mint_lock_when_idle(app_state, _APP_CACHE_USER, cache_server, lock)
|
_prune_model_mint_lock_when_idle(app_state, _APP_CACHE_USER, cache_server, lock)
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ resilience when the primary model is unreachable.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from types import MappingProxyType
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -20,7 +22,12 @@ from turnstone.core.providers import LLMProvider, create_client, create_provider
|
|||||||
|
|
||||||
log = get_logger(__name__)
|
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
|
# 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
|
# 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.
|
# static and skips the write gate entirely.
|
||||||
DYNAMIC_MODEL_AUTH_MODES = frozenset(MODEL_AUTH_MODES) - {"static"}
|
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:
|
def _is_dynamic_auth_mode(mode: str) -> bool:
|
||||||
"""The one in-module spelling of "this mode mints at runtime".
|
"""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
|
# Model configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -144,21 +242,89 @@ class ModelConfig:
|
|||||||
server_compat: dict[str, Any] = field(default_factory=dict)
|
server_compat: dict[str, Any] = field(default_factory=dict)
|
||||||
# Backend credential mode. ``static`` (default) sends ``api_key`` unchanged;
|
# Backend credential mode. ``static`` (default) sends ``api_key`` unchanged;
|
||||||
# ``entra_obo`` mints a caller-delegated Entra token; ``entra_app`` mints a
|
# ``entra_obo`` mints a caller-delegated Entra token; ``entra_app`` mints a
|
||||||
# shared app-identity token. Dynamic tokens are bound as the SDK credential
|
# shared app-identity token; ``rfc8693_obo`` mints a caller-delegated token
|
||||||
# (x-api-key for Anthropic surfaces, Authorization: Bearer for OpenAI-style).
|
# via RFC 8693 token exchange. Dynamic tokens are bound as the SDK
|
||||||
# ``obo_audience`` is the exact operator-approved resource App ID URI.
|
# 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"
|
auth_mode: str = "static"
|
||||||
obo_audience: str = ""
|
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+0000–U+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.
|
"""Validate and normalize one model's backend-auth configuration.
|
||||||
|
|
||||||
DB and config.toml rows share this path so a typo cannot silently downgrade
|
DB and config.toml rows share this path so a typo cannot silently downgrade
|
||||||
dynamic authentication to a static key. Audience values remain literal:
|
dynamic authentication to a static key. Audience and scope values remain
|
||||||
environment expansion would make authorization node-dependent and could
|
literal: environment expansion would make authorization node-dependent and
|
||||||
bypass the admin allow-list and length boundary.
|
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_mode = str(mode or "static").strip() or "static"
|
||||||
normalized_audience = str(audience or "").strip()
|
normalized_audience = str(audience or "").strip()
|
||||||
if normalized_mode not in MODEL_AUTH_MODES:
|
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(
|
raise ModelAuthConfigError(
|
||||||
f"Model '{alias}' requires obo_audience when auth_mode is {normalized_mode!r}"
|
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):
|
_check_auth_text(alias, "obo_audience", normalized_audience)
|
||||||
raise ModelAuthConfigError(f"Model '{alias}' obo_audience contains control characters")
|
# The guard sees the separator-tolerated spelling, NOT the sanitized
|
||||||
if len(normalized_audience) > 2048:
|
# one: the sanctioned separators — tab/newline/CR, the config spellings
|
||||||
raise ModelAuthConfigError(f"Model '{alias}' obo_audience exceeds 2048 characters")
|
# the corpus blesses — read as collapsed spaces, while every OTHER C0
|
||||||
return normalized_mode, normalized_audience
|
# byte stays visible for the refusal. A bare str.split() would swallow
|
||||||
|
# the C0 separator block (U+001C–U+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:
|
def _api_surface_of(cfg: ModelConfig) -> str | None:
|
||||||
@@ -483,6 +659,10 @@ class ModelRegistry:
|
|||||||
key_err = dynamic_auth_key_error(models, app_state)
|
key_err = dynamic_auth_key_error(models, app_state)
|
||||||
if key_err:
|
if key_err:
|
||||||
raise DynamicAuthKeyError(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)
|
_validate_registry_args(models, default, fallback, agent_model, task_model)
|
||||||
with self._client_lock:
|
with self._client_lock:
|
||||||
# FIRST write inside the lock, deliberately BEFORE the map swap
|
# 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.
|
# pre-052 row missing these columns degrades gracefully.
|
||||||
row_surface_persisted_reasoning = bool(row.get("surface_persisted_reasoning", True))
|
row_surface_persisted_reasoning = bool(row.get("surface_persisted_reasoning", True))
|
||||||
row_replay_reasoning = bool(row.get("replay_reasoning_to_model", False))
|
row_replay_reasoning = bool(row.get("replay_reasoning_to_model", False))
|
||||||
# Per-user OBO auth (defaults match a pre-068 row missing the
|
# Per-user OBO auth (defaults match a pre-068/pre-069 row
|
||||||
# columns → "static", no audience → unchanged behaviour).
|
# missing the columns → "static", no audience/scopes →
|
||||||
row_auth_mode, row_obo_audience = _normalize_auth_mode(
|
# unchanged behaviour).
|
||||||
|
row_auth_mode, row_obo_audience, row_obo_scopes = _normalize_auth_mode(
|
||||||
alias,
|
alias,
|
||||||
row.get("auth_mode"),
|
row.get("auth_mode"),
|
||||||
row.get("obo_audience"),
|
row.get("obo_audience"),
|
||||||
|
row.get("obo_scopes"),
|
||||||
)
|
)
|
||||||
configs[alias] = ModelConfig(
|
configs[alias] = ModelConfig(
|
||||||
alias=alias,
|
alias=alias,
|
||||||
@@ -695,6 +877,7 @@ def load_model_registry(
|
|||||||
server_compat=row_server_compat,
|
server_compat=row_server_compat,
|
||||||
auth_mode=row_auth_mode,
|
auth_mode=row_auth_mode,
|
||||||
obo_audience=row_obo_audience,
|
obo_audience=row_obo_audience,
|
||||||
|
obo_scopes=row_obo_scopes,
|
||||||
)
|
)
|
||||||
except ModelAuthConfigError:
|
except ModelAuthConfigError:
|
||||||
# Configuration errors are authoritative row content, not a
|
# Configuration errors are authoritative row content, not a
|
||||||
@@ -753,10 +936,11 @@ def load_model_registry(
|
|||||||
entry_server_compat = entry_caps.pop("server_compat", {})
|
entry_server_compat = entry_caps.pop("server_compat", {})
|
||||||
if not isinstance(entry_server_compat, dict):
|
if not isinstance(entry_server_compat, dict):
|
||||||
entry_server_compat = {}
|
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,
|
alias,
|
||||||
entry.get("auth_mode", "static"),
|
entry.get("auth_mode", "static"),
|
||||||
entry.get("obo_audience", ""),
|
entry.get("obo_audience", ""),
|
||||||
|
entry.get("obo_scopes", ""),
|
||||||
)
|
)
|
||||||
configs[alias] = ModelConfig(
|
configs[alias] = ModelConfig(
|
||||||
alias=alias,
|
alias=alias,
|
||||||
@@ -778,6 +962,7 @@ def load_model_registry(
|
|||||||
server_compat=entry_server_compat,
|
server_compat=entry_server_compat,
|
||||||
auth_mode=entry_auth_mode,
|
auth_mode=entry_auth_mode,
|
||||||
obo_audience=entry_obo_audience,
|
obo_audience=entry_obo_audience,
|
||||||
|
obo_scopes=entry_obo_scopes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Back-compat shim: synthesize a "default" alias from CLI/auto-detected
|
# 3. Back-compat shim: synthesize a "default" alias from CLI/auto-detected
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ def load_oidc_config() -> OIDCConfig:
|
|||||||
# mint legs resolve by exact name, so an unknown profile never mints.
|
# mint legs resolve by exact name, so an unknown profile never mints.
|
||||||
log.warning(
|
log.warning(
|
||||||
"oidc: unknown obo_grant_profile %r (expected one of %s) — "
|
"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",
|
"not mint until this is fixed",
|
||||||
obo_grant_profile,
|
obo_grant_profile,
|
||||||
", ".join(sorted(OBO_GRANT_PROFILES)),
|
", ".join(sorted(OBO_GRANT_PROFILES)),
|
||||||
|
|||||||
+78
-22
@@ -132,7 +132,10 @@ from turnstone.core.metacognition import (
|
|||||||
task_unrenderable_message,
|
task_unrenderable_message,
|
||||||
)
|
)
|
||||||
from turnstone.core.model_registry import (
|
from turnstone.core.model_registry import (
|
||||||
|
APP_IDENTITY_MODEL_AUTH_MODES,
|
||||||
DYNAMIC_MODEL_AUTH_MODES,
|
DYNAMIC_MODEL_AUTH_MODES,
|
||||||
|
MODEL_AUTH_MODE_PROFILES,
|
||||||
|
SCOPES_MODEL_AUTH_MODES,
|
||||||
ModelClientConstructionError,
|
ModelClientConstructionError,
|
||||||
)
|
)
|
||||||
from turnstone.core.model_turn import (
|
from turnstone.core.model_turn import (
|
||||||
@@ -1474,23 +1477,41 @@ class BackendAuthUnavailableError(RuntimeError):
|
|||||||
"""A fail-closed dynamic model credential could not be resolved."""
|
"""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.
|
"""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
|
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
|
the retained logs may hold none of its lines; the per-turn warnings in
|
||||||
``_model_backend_auth_token`` read this instead. Keyed per (prefix,
|
``_model_backend_auth_token`` read this instead. The record lives at the
|
||||||
audience, user): an OBO read passes the minting user so a shared
|
mint-cache key's per-alias granularity — an OBO read additionally passes
|
||||||
audience cannot serve one user's cause on another user's heartbeat,
|
the minting user and the grant leg the mint was asked for, so one
|
||||||
while ``model_app`` reads resolve to the shared app principal the app
|
alias's cause never serves on another's heartbeat — while ``model_app``
|
||||||
mint records under. ``"unknown"`` when nothing was recorded.
|
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
|
# Function-local import, matching the mint-client indirection: this
|
||||||
# module never imports mcp_oauth at module scope.
|
# 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
|
if prefix == "model_obo":
|
||||||
return model_mint_refusal_cause(prefix, audience, key_user) or "unknown"
|
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:
|
class ChatSession:
|
||||||
@@ -6472,8 +6493,9 @@ class ChatSession:
|
|||||||
A dynamic alias with no static key always fails closed rather than
|
A dynamic alias with no static key always fails closed rather than
|
||||||
issuing the SDK-construction placeholder. When a real static key exists,
|
issuing the SDK-construction placeholder. When a real static key exists,
|
||||||
mint failures retain that explicit fallback unless the operator enables
|
mint failures retain that explicit fallback unless the operator enables
|
||||||
``model.auth_fail_closed``. An ``entra_obo`` call with no user always
|
``model.auth_fail_closed``. A delegated-mode call (any dynamic mode
|
||||||
fails closed regardless of fallback policy.
|
outside ``APP_IDENTITY_MODEL_AUTH_MODES``) with no user always fails
|
||||||
|
closed regardless of fallback policy.
|
||||||
"""
|
"""
|
||||||
registry = self._registry
|
registry = self._registry
|
||||||
if registry is None or not alias:
|
if registry is None or not alias:
|
||||||
@@ -6491,7 +6513,11 @@ class ChatSession:
|
|||||||
)
|
)
|
||||||
must_fail_closed = configured_fail_closed or not has_static_key
|
must_fail_closed = configured_fail_closed or not has_static_key
|
||||||
user_id = ""
|
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()
|
user_id = (self._mcp_effective_user_id or "").strip()
|
||||||
if not user_id:
|
if not user_id:
|
||||||
# ``audience=`` is load-bearing on all four warnings in this
|
# ``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}"
|
f"Dynamic backend authentication unavailable for model alias {alias!r}"
|
||||||
)
|
)
|
||||||
return None
|
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/managed identity via client-credentials — Turnstone's own SSO
|
||||||
# app reg. This is used only when the model definition explicitly
|
# app reg. Used only when the model definition explicitly selects
|
||||||
# selects entra_app; missing OBO context never switches grant modes.
|
# an app-identity mode; missing OBO context never switches grant
|
||||||
# The gateway resolves it to one shared virtual account (no per-user
|
# modes. Set membership, not a literal, so this dispatch and the
|
||||||
# attribution).
|
# no-user guard above cannot disagree about which modes carry a
|
||||||
token = mcp.mint_app_token_sync(audience=cfg.obo_audience)
|
# 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:
|
if not token:
|
||||||
log.warning(
|
log.warning(
|
||||||
"model_app.fallback_to_static",
|
"model_app.fallback_to_static",
|
||||||
alias=alias,
|
alias=alias,
|
||||||
audience=cfg.obo_audience,
|
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,
|
has_static_key=has_static_key,
|
||||||
)
|
)
|
||||||
if must_fail_closed:
|
if must_fail_closed:
|
||||||
@@ -6544,8 +6572,36 @@ class ChatSession:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return token
|
return token
|
||||||
# entra_obo — per-user On-Behalf-Of.
|
# Delegated user-context modes (entra_obo / rfc8693_obo) — per-user
|
||||||
token = mcp.mint_model_obo_token_sync(user_id=user_id, audience=cfg.obo_audience)
|
# 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:
|
if not token:
|
||||||
# A user IS driving but the mint yielded nothing (no captured
|
# A user IS driving but the mint yielded nothing (no captured
|
||||||
# credential, decrypt failure, or the AS rejected the grant). Never
|
# credential, decrypt failure, or the AS rejected the grant). Never
|
||||||
@@ -6557,7 +6613,7 @@ class ChatSession:
|
|||||||
alias=alias,
|
alias=alias,
|
||||||
audience=cfg.obo_audience,
|
audience=cfg.obo_audience,
|
||||||
user_id=user_id,
|
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,
|
has_static_key=has_static_key,
|
||||||
)
|
)
|
||||||
if must_fail_closed:
|
if must_fail_closed:
|
||||||
|
|||||||
@@ -5084,6 +5084,7 @@ class PostgreSQLBackend:
|
|||||||
replay_reasoning_to_model: bool = False,
|
replay_reasoning_to_model: bool = False,
|
||||||
auth_mode: str = "static",
|
auth_mode: str = "static",
|
||||||
obo_audience: str = "",
|
obo_audience: str = "",
|
||||||
|
obo_scopes: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
@@ -5108,6 +5109,7 @@ class PostgreSQLBackend:
|
|||||||
replay_reasoning_to_model=1 if replay_reasoning_to_model else 0,
|
replay_reasoning_to_model=1 if replay_reasoning_to_model else 0,
|
||||||
auth_mode=auth_mode,
|
auth_mode=auth_mode,
|
||||||
obo_audience=obo_audience,
|
obo_audience=obo_audience,
|
||||||
|
obo_scopes=obo_scopes,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
created=now,
|
created=now,
|
||||||
updated=now,
|
updated=now,
|
||||||
|
|||||||
@@ -2421,6 +2421,7 @@ class StorageBackend(Protocol):
|
|||||||
replay_reasoning_to_model: bool = False,
|
replay_reasoning_to_model: bool = False,
|
||||||
auth_mode: str = "static",
|
auth_mode: str = "static",
|
||||||
obo_audience: str = "",
|
obo_audience: str = "",
|
||||||
|
obo_scopes: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a model definition. No-op if definition_id already exists."""
|
"""Create a model definition. No-op if definition_id already exists."""
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -840,11 +840,13 @@ model_definitions = sa.Table(
|
|||||||
sa.Column("reasoning_effort", sa.Text, nullable=True),
|
sa.Column("reasoning_effort", sa.Text, nullable=True),
|
||||||
sa.Column("surface_persisted_reasoning", sa.Integer, nullable=False, server_default="1"),
|
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"),
|
sa.Column("replay_reasoning_to_model", sa.Integer, nullable=False, server_default="0"),
|
||||||
# Backend-gateway credential mode. "static" sends api_key, "entra_obo"
|
# Backend-gateway credential mode. "static" sends api_key; "entra_obo" /
|
||||||
# mints a delegated-user token, and "entra_app" mints a shared app token
|
# "rfc8693_obo" mint a delegated-user token and "entra_app" a shared app
|
||||||
# for ``obo_audience`` at call time (migration 068).
|
# 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("auth_mode", sa.Text, nullable=False, server_default="static"),
|
||||||
sa.Column("obo_audience", sa.Text, nullable=False, server_default=""),
|
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_by", sa.Text, nullable=False, server_default=""),
|
||||||
sa.Column("created", sa.Text, nullable=False),
|
sa.Column("created", sa.Text, nullable=False),
|
||||||
sa.Column("updated", sa.Text, nullable=False),
|
sa.Column("updated", sa.Text, nullable=False),
|
||||||
|
|||||||
@@ -5238,6 +5238,7 @@ class SQLiteBackend:
|
|||||||
replay_reasoning_to_model: bool = False,
|
replay_reasoning_to_model: bool = False,
|
||||||
auth_mode: str = "static",
|
auth_mode: str = "static",
|
||||||
obo_audience: str = "",
|
obo_audience: str = "",
|
||||||
|
obo_scopes: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
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),
|
"replay_reasoning_to_model": (1 if replay_reasoning_to_model else 0),
|
||||||
"auth_mode": auth_mode,
|
"auth_mode": auth_mode,
|
||||||
"obo_audience": obo_audience,
|
"obo_audience": obo_audience,
|
||||||
|
"obo_scopes": obo_scopes,
|
||||||
"created_by": created_by,
|
"created_by": created_by,
|
||||||
"created": now,
|
"created": now,
|
||||||
"updated": now,
|
"updated": now,
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ MODEL_DEFINITION_MUTABLE = frozenset(
|
|||||||
"replay_reasoning_to_model",
|
"replay_reasoning_to_model",
|
||||||
"auth_mode",
|
"auth_mode",
|
||||||
"obo_audience",
|
"obo_audience",
|
||||||
|
"obo_scopes",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Sentinel for ``update_model_definition``'s optional conditional-write
|
# 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
@@ -4284,7 +4284,13 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
def internal_model_status(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)
|
registry = getattr(request.app.state, "registry", None)
|
||||||
if registry is None:
|
if registry is None:
|
||||||
return JSONResponse({"models": {}})
|
return JSONResponse({"models": {}})
|
||||||
@@ -4303,6 +4309,7 @@ def internal_model_status(request: Request) -> JSONResponse:
|
|||||||
"reasoning_effort": cfg.reasoning_effort,
|
"reasoning_effort": cfg.reasoning_effort,
|
||||||
"auth_mode": cfg.auth_mode,
|
"auth_mode": cfg.auth_mode,
|
||||||
"obo_audience": cfg.obo_audience,
|
"obo_audience": cfg.obo_audience,
|
||||||
|
"obo_scopes": cfg.obo_scopes,
|
||||||
}
|
}
|
||||||
return JSONResponse({"models": models})
|
return JSONResponse({"models": models})
|
||||||
|
|
||||||
@@ -4693,6 +4700,15 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
await initialize_oidc_state(app.state)
|
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
|
# MCP-OAuth token-at-rest encryption — fail-loud on misconfiguration
|
||||||
# when any mcp_servers row has auth_type='oauth_user'.
|
# when any mcp_servers row has auth_type='oauth_user'.
|
||||||
from turnstone.core.mcp_crypto import initialize_mcp_crypto_state
|
from turnstone.core.mcp_crypto import initialize_mcp_crypto_state
|
||||||
|
|||||||
Reference in New Issue
Block a user