mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(models): default-deny governance and admin UI for per-alias backend auth
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.
This commit is contained in:
+37
-7
@@ -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
|
||||
|
||||
|
||||
+32
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user