fix(egress): stop resolving the egress vault id across identifier namespaces - #1709
Open
go-faustino wants to merge 1 commit into
Open
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
go-faustino
force-pushed
the
fix/egress-identity-namespace-fallback
branch
from
September 1, 2026 11:39
3dd6234 to
5e59473
Compare
…espaces Per-user egress servers returned an empty tools/list after a successful consent, with nothing in the logs. Two paths resolved the vault key through different identifier namespaces, so one human got two vault buckets: the consent write keyed on the OIDC sub, while a gateway-minted self-signed token without an egress_user claim fell through to that token's sub -- the login username (an email on Keycloak) -- and the vend then read the empty bucket. _canonical_egress_user now returns "" for a self_signed caller with no egress_user claim instead of falling through to the username, and the vend refuses a per-user caller whose egress_user claim is empty, logging a warning instead of keying on the token's sub. Every other caller type keeps the existing resolution chain unchanged. Tokens minted before the egress_user claim existed are now refused rather than silently keyed on the wrong bucket. Their TTL is short (8 hours per the UI), so the exposure is one token lifetime after upgrade and the next mint self-heals. Refs agentic-community#1695. Builds on agentic-community#1491 and agentic-community#1530. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
go-faustino
force-pushed
the
fix/egress-identity-namespace-fallback
branch
from
September 2, 2026 09:52
5e59473 to
844b133
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A user completes the browser consent for a per-user egress MCP server, the consent succeeds, and then every per-user egress server returns an empty
tools/listto their IDE client. Nothing is logged. The vend simply returns no token and the caller sees zero tools, so there is no signal anywhere pointing at the cause.The cause is that two code paths resolve the per-user egress vault key through different identifier namespaces, so one human ends up with two parallel vault identities. The consent-write path resolves the OIDC
sub. The vend path, when the caller is a gateway-minted self-signed token that carries noegress_userclaim, falls through to that token'ssub— which is the login username by construction, and on Keycloak the login username is the user's email address. The token is written to one bucket and read from another, and the miss is indistinguishable from "never consented".This is issue #1695. That issue was closed as completed with no linked fix; a maintainer may well have closed it alongside related work, but the code on
mainstill exhibits the problem, which is what this change addresses.This builds on two already-merged PRs rather than reverting either of them. #1491 established that the vault keys on the OIDC
subacross both consent and vend. #1530 established that theegress_userclaim is only trusted from aself_signedtoken this gateway minted itself, so an external issuer cannot inject a vault key. Both are correct and both are preserved here — the trust gate from #1530 is untouched. The remaining gap is what happens when the gate applies but the claim is absent: resolution kept walking down a chain whose remaining entries are, for that specific token type, the login username.The resolution chain
Before, in
_canonical_egress_user:After:
The second line is byte-for-byte what it was. Only the
self_signedcase changes, and only when the claim is missing. A correctly minted self-signed token still resolves to itsegress_user, and non-OIDC callers with only ausernamestill key on the username exactly as documented.The vend in
registry/api/egress_auth_routes.pychanges fromto reading only
egress_user, and then, after the existing per-user check, refusing an empty value with a warning that names the server and the reason.On the non-per-user
subfallbackThe fallback is gone entirely rather than retained for non-per-user callers. Reading the surrounding code,
subis only ever consumed on per-user branches: the very next check returnsconsent_required=Truefor any caller whoseauth_methodis not per-user, beforesubis used for anything. A non-per-user fallback would therefore be dead code that only invites a future reader to reintroduce the namespace crossing.Deliberate behaviour change
Tokens minted before the
egress_userclaim existed will now be refused instead of silently keyed on the wrong bucket. This is intentional. Those tokens have a short TTL — the UI states 8 hours — so the blast radius is bounded to one token lifetime after upgrade, and the next mint carries the claim, which makes the condition self-healing. Crucially, the refusal is now visible: it logs a warning naming the server, where previously the same situation produced an empty tool list and complete silence. Trading one bounded token lifetime of visible, self-correcting refusals for the removal of a silent permanent misroute is the better failure mode, so this does not add a configuration flag to opt out.What this does not change
registry/api/egress_auth_routes.pyaround line 173, in_resolve_target_principal, has a third instance of the same pattern:user_context.get("egress_user") or user_context.get("username") or "". It is left alone deliberately. That line is on the PAT submission and consent principal path, not the vend, and it resolves against a session-deriveduser_contextrather than an mcp-proxy token, so the reasoning about which namespace the remaining candidate belongs to is different and needs its own analysis. Changing it speculatively risks breaking PAT flows for a defect that has not been demonstrated there. It is worth a follow-up on its own.Tests
tests/auth_server/unit/test_egress_mint.pygains five cases in the existingTestCanonicalEgressUserPathssuite: a self-signed token with noegress_userbut a username-shapeddata.submust resolve to""; the same withdata.subject, top-levelsubandusernamepresent, since none of those is an OIDC sub on that token type either; a regression guard that a self-signed token with a validegress_userstill resolves to it; a regression guard that non-self-signed OIDC callers still resolve throughdata.subanddata.subject; and a regression guard that a non-OIDC caller with only ausernamestill keys on the username.tests/unit/egress_auth/test_internal_egress_token_route.pygains a case asserting that a per-user caller with noegress_userclaim getsconsent_required=Truewith no token, no service call, and a warning naming the server, usingcaplog.The shared
_claims()helper intest_internal_egress_token_route.pyandtest_pat_vend.pynow includesegress_useralongsidesub, so those fixtures reflect the shape of a token the current auth-server actually mints. Those suites test upstream binding, path normalisation and PAT vending, not the removed fallback, and all their assertions are unchanged.Counts from a local run with the repository's pytest:
tests/auth_server/unit/test_egress_mint.pyplustests/unit/egress_auth/: 307 passed before, 313 passed after.tests/auth_server/andtests/unit/: 7344 passed, 1 failed, 8 skipped. The single failure istests/unit/audit/test_routes.py::TestAdminOnlyAccess::test_rejects_non_admin_users, a HypothesisDeadlineExceededflake (the test took 5008 ms against a 200 ms deadline, reported asFlakyFailureafter it did not reproduce on the retry). It is unrelated to this change, touches no egress code, and passes on its own both with and without these edits.docs/egress-credential-vault.mdis updated to record both fail-closed points in the section describing the canonicaluser_id.