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:
Patrick Buckley
2026-08-03 18:15:27 -07:00
parent 1a4f411cd5
commit 33ace975d2
56 changed files with 6813 additions and 649 deletions
+37 -7
View File
@@ -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
View File
@@ -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