Skip to content

feat(auth): resolve service-principal group membership via Graph for app-only trust tokens - #6757

Open
jonpspri wants to merge 8 commits into
docs/5906-trust-mode-docs-gatefrom
feat/6756-app-only-graph-lookup
Open

jonpspri wants to merge 8 commits into
docs/5906-trust-mode-docs-gatefrom
feat/6756-app-only-graph-lookup

Conversation

@jonpspri

@jonpspri jonpspri commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Adds app-only (client-credentials) trust-token group resolution to JWT-trust mode (issue #6756, epic #5885).

App-only Entra tokens carry idtyp="app", a roles claim, and no groups claim. Entra emits no overage markers for them, so the #5977 overage dispatch never triggers — and /users/{oid}/getMemberObjects would fail for a service principal (a service principal is not a user). Under jwt_trust_overage_policy="graph_lookup" this PR resolves the service principal's security groups through POST /servicePrincipals/{oid}/getMemberObjects and maps them through external_group_mappings.

Changes:

  • detect_app_only_token(payload): True when idtyp == "app" (mcpgateway/utils/trusted_claims.py).
  • EntraGraphClient endpoint selection: /servicePrincipals/{oid}/getMemberObjects for app-only tokens; /users/{oid}/getMemberObjects unchanged for user tokens. Client-credentials grant only; the inbound bearer token is never used. Same oid-keyed Redis cache, TTL bounded by exp.
  • Trust-path dispatch (get_current_user): idtyp == "app" AND groups absent AND graph_lookup -> Graph resolve (cached) -> payload copy -> resolve_external_groups_to_teams. Under fail_closed (default) and proceed_without_groups an app token without groups authenticates with token_teams=[]; the app-role path (Admin claim feeds both admin tracks atomically + parity tests #5902) stays intact.
  • make_trusted_test_jwt: new idtyp kwarg; default output unchanged.

TDD: 7 new tests in tests/unit/mcpgateway/test_entra_graph_client.py (red first: ImportError on the new symbols). URL capture asserts /servicePrincipals/{id} is called and /users/ never is for app tokens; fail_closed pins a roles=["viewer"] app token (authenticated, token_teams=[], viewer role granted); Graph failure under graph_lookup -> 401; Redis read error -> cache miss -> live call. All 11 pre-existing tests in the file pass unmodified (user-token overage regression).

Gate:

  • make ruff — All checks passed!
  • make test — 23411 passed, 879 skipped, 2 xfailed. (Two earlier full-suite runs each flaked on the pre-existing wall-clock benchmark test_trust_p99_within_2x_default under load; it passes in isolation and in the final green run. This PR adds no timing tests.)
  • Pre-commit hooks on commit: ruff check/format, interrogate, bandit, IBM detect-secrets — all pass.

Note: the commit also carries the pre-staged .secrets.baseline regeneration (line-number bookkeeping for existing is_secret: false entries), which was already in the index from the stack work.

Risk to existing users: none — every new branch is trust-mode + graph_lookup gated; default mode and the user-token overage path verified green in the full suite.

Stack: B.15 of epic #5885 (base: #6755).

Closes #6756


External-IdP funnel parity (folded in)

build_trusted_external_identity now resolves app-only tokens' service-principal groups under jwt_trust_overage_policy=graph_lookup, mirroring get_current_user: when a token carries idtyp == "app", no groups claim, and no overage marker, the funnel queries /servicePrincipals/{oid}/getMemberObjects with the provider's client-credentials token (the inbound bearer is never used) and feeds the resolved IDs to the same external-group resolver. A Graph failure denies with metric reason service_principal_groups_unresolved (fail-closed). fail_closed and proceed_without_groups keep the previous behavior. Four deny-path/positive unit tests cover the dispatch in test_external_idp_trust_mode.py; use case 5 of the live Entra suite (#6931) proves it against a real tenant.

@jonpspri
jonpspri added this pull request to stack #6729 September 10, 2026 07:50
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from b4dc120 to 054ffd8 Compare September 10, 2026 08:06
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 054ffd8 to 6c8f36b Compare September 10, 2026 14:20
@jonpspri
jonpspri removed this pull request from stack #6729 September 12, 2026 08:50
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 6c8f36b to ff2fc67 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/6756-app-only-graph-lookup branch from ff2fc67 to 5ee3ba0 Compare September 12, 2026 09:20
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 5ee3ba0 to a880b4b Compare September 12, 2026 09:48
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch 2 times, most recently from 590012e to 899809c Compare September 12, 2026 16:45
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 899809c to 3a6e02d Compare September 12, 2026 17:20
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 3a6e02d to 44f883b Compare September 12, 2026 17:35
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 44f883b to 9a59c6d Compare September 12, 2026 17:54
@jonpspri

Copy link
Copy Markdown
Collaborator Author

Requirement note (remediation) — validator wired

The group-existence validator is real now (5412c7719). Status semantics: 200 gives "valid"; 404 gives "graph_group_not_found" (row allowed, resolver fails closed at read); errors give "unknown" with a warning; non-Microsoft issuers get no call.

Also on this PR: a lazy import broke an import cycle that the remediation set introduced (aae0b9707), and the identity-domains doc row was refreshed. See the notes on #5977 and #5976.

@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from aae0b97 to 88570b3 Compare September 21, 2026 09:20
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch 2 times, most recently from 44af82f to d47e6f7 Compare September 22, 2026 09:51
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from d47e6f7 to 537bbdc Compare September 22, 2026 09:55
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from cae9547 to a20a644 Compare October 3, 2026 10:06
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from a20a644 to 853bea1 Compare October 3, 2026 16:31
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 853bea1 to 31c0ede Compare October 5, 2026 10:03
jonpspri and others added 8 commits October 5, 2026 13:37
…app-only trust tokens

Signed-off-by: Jonathan Springer <jps@s390x.com>
…group mappings

Replace the disabled group_exists_validator stub (#5976) with the real
Microsoft Graph validator (#5977): Entra issuers resolve the SSO provider
record for the issuer (same issuer->provider resolution as the trust-mode
overage path) and GET /v1.0/groups/<id> with an app-only token. Graph 200
-> valid, 404 -> graph_group_not_found (recorded, not rejected; the
resolver fails closed at read time), any other failure -> unknown under
the existing warn-and-allow contract. Non-Entra issuers and issuers
without app-only credentials keep the disabled-stub posture (valid) with
a log line. The Entra hosts set is single-sourced in entra_graph_client
and aliased by OAuthManager._ENTRA_HOSTS. The validator seam stays
module-level injectable; sync callables remain supported.

Signed-off-by: Jonathan Springer <jps@s390x.com>
…ycle; refresh identity-domains trust row

Signed-off-by: Jonathan Springer <jps@s390x.com>
Fold the two post-rebase baseline regenerations into one commit. The
plan-document audit entries they carried no longer apply: the stack
removed every docs/plans file.

Signed-off-by: Jonathan Springer <jps@s390x.com>
…IdP trust funnel

An Entra app-only token (idtyp=app) carries no groups claim and no
overage markers. The bearer funnel already resolved the service
principal's groups under jwt_trust_overage_policy=graph_lookup; the
external-IdP funnel did not, so the same token shape gave different team
visibility on the two surfaces.

build_trusted_external_identity now mirrors the bearer funnel: under
graph_lookup it resolves groups through /servicePrincipals/{oid}/
getMemberObjects with the provider's client-credentials token, feeds the
resolved IDs to the same external-group resolver, and denies with reason
service_principal_groups_unresolved when Graph fails (fail-closed).
fail_closed (default) and proceed_without_groups keep the previous
behavior.

Signed-off-by: Jonathan Springer <jps@s390x.com>
With JWT_TRUST_MODE=jwt-trust, REST accepts a token from a trust root.
The streamable-HTTP MCP endpoint rejects the same token with 401.
MCP clients use that endpoint, so they cannot authenticate in trust mode.

The cause is that _StreamableHttpAuthHandler._auth_jwt never runs the
trusted-issuer branch. REST reaches that branch through
auth.get_current_user and _try_external_verification. The transport sends
the token to the per-server OAuth lookup or to the internal-JWT verifier.
Both reject a trust-root token.

Add _StreamableHttpAuthHandler._auth_trusted_issuer. _auth_jwt calls it
first when trust mode is on. It applies the REST contract:
- Call _maybe_verify_external with fail_closed=True.
- Reject a failed trust-root token with 401. Log the security event
  trust_root_token_rejected_ingress.
- Check the revocation claim against the revocation store on each
  request. Reject a revoked token or a missing claim with 401. Return 503
  on a database error.
- Set the user context and the trace context from the claims.
A token from an issuer that is not a trust root continues to the
existing paths.

AuthContextMiddleware also rejects revoked tokens, but only when security
logging, SIEM, the admin API, or password-change enforcement is on. The
handler check keeps revocation in force when all four are off.

_check_streamable_permission now sends the claims-derived roles and admin
flag to RBAC for trust principals only.

Correct auth-token-dispatch.md: get_current_user() is the choke point for
REST routes only. Describe the trusted-issuer step on the MCP transport.

Add unit tests for the handler and the RBAC helper. Add MCP-endpoint rows
to the live trust-mode ingress test.

Signed-off-by: DJ Lynch <daniel.lynch2016@gmail.com>
Review of the trust-mode transport fix found five problems.

RBAC gave the claims-derived admin bypass to any context with
token_use="trusted". The internal-JWT path copies that claim from the
token, so a gateway-signed token with token_use="trusted" and is_admin
skipped the DB admin check. _check_streamable_permission now requires the
trust_principal marker, which only _auth_trusted_issuer sets, and trust
mode ON. The internal-JWT path also returns 401 for token_use="trusted"
when trust mode is OFF, as REST does.

The trust branch ran before the per-server OAuth lookup. An
oauth_enabled server whose IdP is also a trust root got a 401 for a token
minted for its own audience. The trust branch now runs only when the
server's OAuth path does not handle the token.

The transport accepted any payload from _maybe_verify_external. Its
identity cache can return a payload that is not trusted. The transport
now requires token_use="trusted" and returns 401 otherwise.

An unexpected error in the trust branch returned 500. It now returns 401,
as the internal-JWT path does.

The transport built the context from the cached payload. REST runs
extract_trusted_principal on every request.

Move the REST trust steps into two helpers in auth.py:
- _is_trust_eligible: the token_use="trusted" dispatch rule, including
  the 401 when trust mode is OFF.
- _resolve_trusted_principal: group-overage resolution,
  extract_trusted_principal, the email fallback, and the revocation
  check.
get_current_user calls both, with no change in behavior.
_auth_trusted_issuer calls _try_external_verification and both helpers.
It maps an HTTPException to the same status, detail and headers.

Also put team_name in the trust context and forward roles across the
internal MCP seam.

Signed-off-by: DJ Lynch <daniel.lynch2016@gmail.com>
The choke point calls verify_credentials_cached since main's shared
verifier landed (#6396). Rename the patch target so the double controls
the funnel under test.

Signed-off-by: Jonathan Springer <jps@s390x.com>
@jonpspri
jonpspri force-pushed the feat/6756-app-only-graph-lookup branch from 31c0ede to 6328d99 Compare October 5, 2026 12:39

This branch has not been deployed

No deployments
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.

2 participants