Skip to content

feat(auth): add JWT-trust verification path with claims-derived identity - #6746

Open
jonpspri wants to merge 4 commits into
feat/5977-entra-graph-overagefrom
feat/5900-trust-branch
Open

feat(auth): add JWT-trust verification path with claims-derived identity#6746
jonpspri wants to merge 4 commits into
feat/5977-entra-graph-overagefrom
feat/5900-trust-branch

Conversation

@jonpspri

@jonpspri jonpspri commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Wires the trust-eligible branch into get_current_user per the #5896 dispatch rule. A token marked token_use="trusted" with trust mode OFF is rejected 401 before the default funnel — the UUID heuristic could otherwise re-attribute identity. With trust mode ON, gateway-signed trust tokens authenticate via extract_trusted_principal: identity, teams, and roles come from the mapped claims; the external-group resolver translates group claims to CF team IDs (raw group IDs never reach token_teams); the configured revocation claim (jti default, uti supported) is checked against the revocation store on every request. The local user lookup, is_active, the UUID seam, and DB team resolution are skipped by design. The accepted posture change (no per-user is_active kill-switch in trust mode) is documented in code and configuration.md. Overage-marked tokens dispatch on jwt_trust_overage_policy. request.state mirrors the default-mode shape (token_teams, derived team_id, token_use="trusted", jti, auth_method).

Auth-cache Redis keys gain the mode segment (mcpgw:auth:{version}:{mode}:{key_type}:{identifier}) — a mode flip cold-starts all auth caches with no manual bump; the A.6 prefix test and doctest updated per plan.

All strict-xfail suites from #5896 (branch a), #5976, and #6272 flip green in this PR; exactly one xfail remains — the external-IdP branch (b) row pointing at #5903.

Tested with:

Acceptance criteria of #5900 are met. Risk to existing users: none with trust mode off (default) — the branch is unreachable; default-mode paths byte-identical.

Stack: B.8 of epic #5885 (base: #6745).

Closes #5900

@jonpspri
jonpspri added this pull request to stack #6729 September 10, 2026 01:36
@jonpspri
jonpspri marked this pull request as ready for review September 10, 2026 01:36
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 483b589 to 49a65bb Compare September 10, 2026 06:23
@jonpspri
jonpspri removed this pull request from stack #6729 September 12, 2026 08:50
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 49a65bb to 5e67afe Compare September 12, 2026 09:07
@jonpspri
jonpspri added this pull request to stack #6798 September 12, 2026 09:08
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 5e67afe to 660b025 Compare September 12, 2026 09:20
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 660b025 to de08702 Compare September 12, 2026 09:48
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 72b540c to 36cd3c5 Compare September 12, 2026 16:45
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 36cd3c5 to a68f67d Compare September 12, 2026 17:20
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from a68f67d to 671e292 Compare September 12, 2026 17:35
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from 671e292 to 58e2687 Compare September 12, 2026 17:54
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch 2 times, most recently from dde2d02 to fd192e0 Compare September 12, 2026 18:33
Wire the trust-eligible branch in get_current_user per the #5896 dispatch
rule: token_use="trusted" with trust mode ON authenticates from mapped
claims via extract_trusted_principal; the configured revocation claim is
checked with _check_token_revoked_sync; user lookup, is_active, the
UUID->email seam, and DB team resolution are skipped. A marked token with
trust mode OFF gets 401 and never enters the default funnel. Entra
group-overage markers dispatch on jwt_trust_overage_policy (#5977).

Extra scope per the work order:
- AuthCache Redis keys gain the auth-mode segment
  (mcpgw:auth:{version}:{mode}:{key_type}:{identifier}); a mode flip
  cold-starts the cache. The A.6 prefix test and the doctest are updated.
- tests/helpers/auth.py gains make_trusted_test_jwt.
- B.1 matrix branch-(a) rows and the WO-B.4/B.5 strict-xfail tests flip to
  green; only the branch-(b) row stays strict-xfail with a pointer to
  #5903.
- New smoke suite asserts zero email_users reads on the trust path.
- Live-gateway test file (e2e, skip_no_gateway) covers both modes.
- configuration.md documents the accepted posture change: no per-user
  is_active kill-switch in trust mode.

Signed-off-by: Jonathan Springer <jps@s390x.com>
Add tests/live_gateway/test_trust_mode_entra_barrier.py next to the
existing trust-mode RBAC live checks. It documents, with a pinned
assertion, that a genuine Entra-signed end-user access token sent to
POST /a2a/<agent>/invoke is rejected with 401 at the authentication
choke point before external verification, group mapping, RBAC,
visibility, or agent lookup can run: get_current_user() calls the
internal-only verify_jwt_token_cached() and never dispatches external
issuers to the JWKS path in verify_credentials_cached().

The forged-token control asserts 401 in every mode. Both tests
self-skip without a reachable gateway, without the untracked token
files (ENTRA_BARRIER_TOKEN_DIR), and the pinned non-compliance test
additionally requires JWT_TRUST_MODE=jwt-trust. Token material is
never logged.

When the ingress is wired to the external-aware verifier, the
valid-token expectation flips to 404 (deliberately nonexistent agent);
the docstring marks that flip point.

Verified live against a standalone trust-mode gateway: 2 passed with
tokens; per-guard skips verified for no-gateway, no-token-files, and
db-mode runs.

Signed-off-by: Jonathan Springer <jps@s390x.com>
get_current_user_with_permissions dropped the trust principal's mapped
role names and claims-derived admin flag: only email, is_admin, teams,
and user_id survived into the context dict. Carry them so the
permission layer can consume claims identity (#5900, F2 first half):

- Trust path (VirtualPrincipal, token_use="trusted"): the context gains
  roles (mapped role names, default []) and token_is_admin (the
  claims-derived admin flag, default False), both sourced from the
  principal; token_teams falls back to the principal's mapped teams
  when request.state was not populated (the live trust branch in
  get_current_user always sets it, so the fallback is a no-op there).
- Non-trust returns (internal JWT, proxy, anonymous, disabled) gain
  the same keys with neutral defaults (roles=[], token_is_admin=False)
  so downstream consumers can rely on key presence. token_teams is
  added where previously absent with the value downstream .get()
  already produced ([] for anonymous; None for admin-capable paths,
  preserving the None=unrestricted/[]=public-only scoping semantic).
- DB-derived is_admin never leaks into token_is_admin on non-trust
  paths.

No permission derivation happens on this branch: role names are only
carried. The single name-resolution point remains
extract_trusted_principal, which validates each mapping-supplied role
name scope-exactly via
mcpgateway.services.role_resolution.resolve_mapping_role (single
active row, team>global, lowest id, never unioned) per the NB6 rule.

The Entra A2A ingress barrier stays pinned at 401; ingress is a
separate up-stack task.

Signed-off-by: Jonathan Springer <jps@s390x.com>
The claims-identity change dropped the pre-existing token_use entry
from get_current_user_with_permissions' authenticated return dict.
Two live consumers read it from exactly this dict:
_resolve_team_and_check_mode derives the accessed resource's team only
for session/api tokens (without the key it falls through to
check_any_team=True, aggregating roles across all teams and broadening
mutate permissions cross-team), and the OAuth router's
_build_user_context branches on token_use == "session" for
DB-authoritative vault path selection. Restore the key alongside the
additive roles/token_is_admin entries and pin it with regression
tests: a session-type context carries token_use plus the neutral
claims keys, and _resolve_team_and_check_mode derives the resource
team for a session context while broadening to any-team without it.

Signed-off-by: Jonathan Springer <jps@s390x.com>
@jonpspri
jonpspri force-pushed the feat/5900-trust-branch branch from e18d043 to 0a27c27 Compare September 12, 2026 21:11
@jonpspri

Copy link
Copy Markdown
Collaborator Author

Requirement note (remediation) — context keys

The trust-path context now carries roles, token_is_admin, and token_teams from the principal. All authenticated paths carry the keys with neutral defaults.

Review caught one regression: the patch first dropped the token_use key. That key was restored with consumer-level regression tests (e18d043de).

The barrier test in this PR was re-pinned upstack (PR #6750). A valid Entra token with no seeded trust root keeps 401 (fall-through). See the notes on #5900.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trust-mode branch in get_current_user (skip user lookup, keep jti revocation)

1 participant