From 33ace975d2a79bf2feb391d5b896208574c72c3e Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 3 Aug 2026 18:15:27 -0700 Subject: [PATCH] feat(models): default-deny governance and admin UI for per-alias backend auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the per-alias Entra OBO/app-identity backend auth: the console write path now applies default-deny field classification, the admin shelf gains full backend-auth support, and the session/registry rebind machinery is hardened for config changes landing under live sessions. Console write gate: - Default-deny classification: any non-neutral change to a row that is or becomes dynamic requires admin.mcp plus validation; the provably auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a live-schema classification test forces every future column to be classified. The derivation is a pure function (_derive_auth_gate) with unit-pinned exclusivity invariants. - Two-tier validation mirroring the MCP oauth_obo validator: the row tier (audience allow-list) runs on every gated write; the posture tier (OIDC configured, token store present) runs on pair changes and on enable-arming. - Pure-disable carve-out: disabling a dynamic row is de-escalation and is never blocked — admin.models suffices and validation is skipped, including for rows with corrupt or skewed stored values. - Capabilities are compared canonically (key order, integral floats), the audience compare normalizes both sides, and staging an audience on a static row is refused on both write twins. - Calibrate writes the capabilities column under an enforced confinement invariant with a compare-and-swap persist. Admin shelf: - Backend-auth section with a per-open constraints fetch (GET /model-definitions/auth-constraints: audience allow-list, grant profile, dynamic modes), datalist audience suggestions, server-defined modes preserved on round-trip, and permission-aware visibility built on cache-skew-safe helpers shared through auth.js. - Refused live-registry swaps surface as an amber registry_warning on the write, delete, reload, and calibrate responses; audit rows carry auth_gated / auth_disarmed markers visible in the audit view. Registry and sessions: - The encryption-key requirement for dynamic auth is enforced inside ModelRegistry.reload() itself — nodes refuse with 503 and the console records coord_registry_error — and reload bumps the generation before the map swap so a racing reader can never pair a stale generation with new maps. - resolve()/resolve_binding() return the generation from inside the registry lock; sessions rebind per send on generation change with atomic client/provider/config commits, fallback-first handling of removed or unconstructable aliases, and judge/limiter resets only when the binding actually changed. - Mint refusals record per-user causes surfaced in the per-turn heartbeat logs; misconfiguration warnings are deduplicated with bounded state. Verification: 10417 tests (99 added on this branch), a 71-scenario browser harness over the real admin shelf, and a live rfc8693 token-exchange e2e run (MCP legs verified end to end; the model-leg scope gap is tracked as #955 under a narrow known-gap signature). Closes #950. --- docs/oidc.md | 44 +- docs/settings.md | 35 +- scripts/livepass.py | 56 +- scripts/obo-e2e/keycloak_e2e.py | 150 +- sdk/typescript/openapi-console.json | 334 +++- sdk/typescript/openapi-server.json | 4 +- tests/_coord_test_helpers.py | 2 +- tests/_oidc_test_helpers.py | 57 + tests/test_admin_calibrate_endpoint.py | 159 ++ tests/test_admin_model_registry_refresh.py | 1935 +++++++++++++++++++- tests/test_audio.py | 2 +- tests/test_console_session_factory.py | 3 +- tests/test_coordinator_end_to_end.py | 2 +- tests/test_coordinator_governance.py | 7 +- tests/test_doctor.py | 2 +- tests/test_judge.py | 50 +- tests/test_mcp_obo_mint.py | 98 +- tests/test_model_provider_obo.py | 282 ++- tests/test_model_registry.py | 811 +++++++- tests/test_openapi.py | 42 + tests/test_output_guard_judge.py | 25 +- tests/test_rerank_calibrate.py | 57 + tests/test_server_lifespan_mcp_crypto.py | 37 + tests/test_server_node_models_metadata.py | 137 ++ tests/test_session.py | 15 +- tests/test_session_attachments.py | 5 +- tests/test_session_backend_error_format.py | 25 + tests/test_sessions.py | 120 +- turnstone/api/console_schemas.py | 124 +- turnstone/api/console_spec.py | 33 +- turnstone/cli.py | 5 +- turnstone/console/server.py | 838 +++++++-- turnstone/console/session_factory.py | 5 +- turnstone/console/static/admin.js | 433 ++++- turnstone/console/static/app.js | 36 +- turnstone/console/static/index.html | 90 +- turnstone/core/audio.py | 6 +- turnstone/core/judge.py | 27 +- turnstone/core/mcp_crypto.py | 20 +- turnstone/core/mcp_oauth.py | 232 ++- turnstone/core/model_registry.py | 302 ++- turnstone/core/oidc.py | 8 +- turnstone/core/output_guard_judge.py | 26 +- turnstone/core/rerank_calibrate.py | 79 + turnstone/core/session.py | 490 ++++- turnstone/core/storage/_postgresql.py | 26 +- turnstone/core/storage/_protocol.py | 14 +- turnstone/core/storage/_sqlite.py | 26 +- turnstone/core/storage/_utils.py | 5 + turnstone/core/web_helpers.py | 5 + turnstone/doctor.py | 2 +- turnstone/server.py | 84 +- turnstone/shared_static/auth.js | 32 + turnstone/shared_static/base.css | 4 + turnstone/shared_static/hatch.css | 8 +- turnstone/shared_static/toast.js | 6 +- 56 files changed, 6813 insertions(+), 649 deletions(-) create mode 100644 tests/_oidc_test_helpers.py diff --git a/docs/oidc.md b/docs/oidc.md index 5d4f1998..4a12e179 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -144,13 +144,43 @@ the provider SDK's native credential option rather than injecting an override header. The grant mode is never inferred: missing user context or a failed OBO mint cannot switch an `entra_obo` definition to client credentials. -`entra_obo` requires `capture_user_credential = true`, the MCP encryption key, -and delegated/admin-consented permission to the audience. `entra_app` requires -`obo_grant_profile = "entra"` and a confidential-client secret; RFC 8693 -client-credentials is not implemented. Configure the permitted resource IDs in -the runtime setting `model.auth_audience_allowlist` before saving dynamic model -definitions. See [Settings](settings.md#model-backend-authentication) for -permissions, failure policy, and lane identity rules. +`entra_obo` needs the MCP encryption key, a credential captured for the driving +user, and delegated/admin-consented permission to the audience. It works under +either grant profile, with one RFC 8693 caveat: the model mint sends **no scope +parameter** (model definitions carry no per-row scopes, unlike MCP servers), so +the IdP must grant the alias's audience to the app client **by default**; on +Keycloak the exchange otherwise fails with "Requested audience not available" +(see issue #955 for the tracked fix). Turning `capture_user_credential` off +later stops *new* captures but does not invalidate credentials already stored, +so existing users keep minting. `entra_app` requires `obo_grant_profile = +"entra"` and a confidential-client secret; RFC 8693 client-credentials is not +implemented. Configure the permitted resource IDs in the runtime setting +`model.auth_audience_allowlist` before saving dynamic model definitions. +De-listing an audience later blocks every write that would arm or re-aim a +definition at it, but does not stop aliases already configured from minting — +disabling the row (the `admin.models` disarm lever) is what stops minting. See +[Settings](settings.md#model-backend-authentication) for permissions, failure +policy, and lane identity rules. + +An unrecognised `obo_grant_profile` is warned about at startup and **rejected +at the write choke points**: configuring an `oauth_obo` MCP server or a dynamic +model alias returns a 400 that echoes the configured value, so the typo is the +diagnosis. At runtime an unknown profile never mints — the mint legs resolve by +exact name; the full cause detail is logged once per audience, and every +affected call still logs its per-turn fallback or refusal naming the alias, +the target audience, and the last recorded cause (`cause=` — for example +`unsupported_grant_profile` or `oidc_not_enabled`) — so a pre-existing row +degrades loudly, with the reason visible mid-incident even after the +once-per-process line has rotated out of retained logs, rather than silently +swapping per-user attribution for the shared static key. + +The `[security]` token encryption key is deployment-wide, not per-host: rows are +encrypted with `MultiFernet` and carry no key id, so every host that reads them +needs the same keyring. That includes the console, which mints for +coordinator-hosted sessions. A node that needs the key and lacks it refuses to +start; the console starts but withholds its coordinator subsystem and shows +the key requirement as the remediation error instead of failing silently at +call time. ### config.toml alternative diff --git a/docs/settings.md b/docs/settings.md index dbc8b1bf..aec3f955 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -67,9 +67,38 @@ Model definitions support three backend credential modes: Dynamic modes require an exact `obo_audience` resource App ID URI. Before an admin can save one, an operator must add that literal audience to `model.auth_audience_allowlist` (comma- or newline-separated). Wildcards and -base-URL host matching are intentionally unsupported. Changing dynamic auth, -its audience, or the gateway `base_url` also requires `admin.mcp`; service -tokens do not bypass this capability-escalation gate. +base-URL host matching are intentionally unsupported, and a row whose +effective mode is `static` refuses to store a new non-empty `obo_audience` on +either create or update — an audience cannot be staged for a later flip +(clearing a stale value, or re-saving it unchanged, stays allowed). On a row +that is (or becomes) dynamic, every change except the tuning fields — context +window, temperature, max tokens, reasoning effort, and the two +reasoning-persistence toggles — also requires `admin.mcp`; service tokens do +not bypass this capability-escalation gate. The one exception is +de-escalation: a save whose only gated change is switching `enabled` off is a +pure disable, needs only `admin.models`, and skips validation — a de-listed +audience must never block disarming its own row. The gate is deny-by-default: +a field counts as auth-relevant unless it is provably neutral, so re-enabling +a disabled dynamic row, re-pointing its `base_url`, or swapping its provider +or alias all escalate. + +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 +touches a dynamic configuration, so a revoked audience can be neither silently +re-pointed at a new `base_url` nor re-armed by an enable flip. Deployment +posture — the token encryption key installed, single sign-on configured, and +the grant profile valid and able to carry the mode — is checked when a write +*chooses* the mode/audience pair and when it re-enables a disabled dynamic +row (arming is the flip that resumes minting, so it must meet what minting +needs); other edits to an existing row stay open if the deployment's posture +changed after it was saved (its mints warn at runtime instead). Refusals name +their cause and echo the configured value. + +One asymmetry to be aware of: the write path counts a transient discovery +outage (`enabled=false`, retryable) as configured, but the mints themselves +require discovery to have completed — a config saved during an outage starts +minting only once any authenticated request heals discovery. Until then calls +warn and follow the fail-open/fail-closed policy above. `entra_app` is supported only with `[oidc] obo_grant_profile = "entra"`. Judge, output-guard, perception, utility, and sub-agent lanes inherit the diff --git a/scripts/livepass.py b/scripts/livepass.py index e3ce135f..9eff7ec3 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -405,6 +405,28 @@ CONSOLE_TEMPLATE = """
+ +