@@ -488,6 +488,185 @@ fn write_multi_source_config() -> (TempDir, String) {
488488 ( dir, path_str)
489489}
490490
491+ /// Write a CPEX YAML that exercises ROUTE-SCOPED identity. A global
492+ /// resolver (`id-global`, reads `Authorization`) is listed in
493+ /// `global.authentication`; a second resolver (`id-route`, reads
494+ /// `X-Route-Token`) is bound ONLY to the `scoped-tool` route via a
495+ /// `replace_inherited` `authentication:` block. `open-tool` carries no
496+ /// route-level `authentication:`, so it inherits the global resolver.
497+ /// Both plugins validate the same HS256 material — only the HEADER each
498+ /// reads and the route each is bound to differ, which is precisely what
499+ /// route-scoping must select on.
500+ #[ expect(
501+ clippy:: too_many_lines,
502+ reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test"
503+ ) ]
504+ fn write_route_scoped_identity_config ( ) -> ( TempDir , String ) {
505+ let dir = TempDir :: new ( ) . expect ( "create tempdir" ) ;
506+ let cfg_path = dir. path ( ) . join ( "cpex.yaml" ) ;
507+
508+ let yaml = format ! (
509+ r#"plugin_settings:
510+ # Route-scoped dispatch only engages when routing is enabled.
511+ routing_enabled: true
512+ plugins:
513+ - name: id-global
514+ kind: identity/jwt
515+ hooks:
516+ - identity.resolve
517+ mode: sequential
518+ priority: 10
519+ on_error: fail
520+ config:
521+ header: Authorization
522+ trusted_issuers:
523+ - issuer: "{TEST_ISSUER}"
524+ audiences: ["{TEST_AUDIENCE}"]
525+ algorithms: ["HS256"]
526+ decoding_key:
527+ kind: secret
528+ secret: "{TEST_SECRET}"
529+ leeway_seconds: 60
530+ claim_mapper: standard
531+ - name: id-route
532+ kind: identity/jwt
533+ hooks:
534+ - identity.resolve
535+ mode: sequential
536+ priority: 20
537+ on_error: fail
538+ config:
539+ header: X-Route-Token
540+ trusted_issuers:
541+ - issuer: "{TEST_ISSUER}"
542+ audiences: ["{TEST_AUDIENCE}"]
543+ algorithms: ["HS256"]
544+ decoding_key:
545+ kind: secret
546+ secret: "{TEST_SECRET}"
547+ leeway_seconds: 60
548+ claim_mapper: standard
549+ global:
550+ authentication:
551+ - id-global
552+ routes:
553+ # `apl` is required for a route to run identity/policy at all — without
554+ # a policy the body phase treats the route as passthrough.
555+ - tool: open-tool
556+ apl:
557+ pre_invocation:
558+ - "require(authenticated)"
559+ - tool: scoped-tool
560+ authentication:
561+ replace_inherited: true
562+ steps:
563+ - id-route
564+ apl:
565+ pre_invocation:
566+ - "require(authenticated)"
567+ "#
568+ ) ;
569+
570+ std:: fs:: write ( & cfg_path, yaml) . expect ( "write cpex.yaml" ) ;
571+ let path_str = cfg_path. to_str ( ) . expect ( "utf8 path" ) . to_owned ( ) ;
572+ ( dir, path_str)
573+ }
574+
575+ /// Drive a `tools/call` for `tool`, carrying `token` in `header` as the
576+ /// ONLY identity header on the request, and return the body-phase
577+ /// action. The classifier metadata (`mcp.method` / `mcp.name`) is what
578+ /// tells the filter which route this is — and therefore which identity
579+ /// resolvers to scope to.
580+ async fn dispatch_tool_with_header (
581+ filter : & PolicyFilter ,
582+ tool : & str ,
583+ header : & ' static str ,
584+ token : & str ,
585+ ) -> FilterAction {
586+ let mut req = make_request ( Method :: POST , "/" ) ;
587+ req. headers . insert (
588+ header,
589+ HeaderValue :: from_str ( & format ! ( "Bearer {token}" ) ) . expect ( "header value" ) ,
590+ ) ;
591+ let mut ctx = make_filter_context ( & req) ;
592+ ctx. set_metadata ( "mcp.method" , "tools/call" ) ;
593+ ctx. set_metadata ( "mcp.name" , tool) ;
594+ let body = bytes:: Bytes :: from_static (
595+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"t","arguments":{}}}"# ,
596+ ) ;
597+ filter
598+ . on_request_body ( & mut ctx, & mut Some ( body) , true )
599+ . await
600+ . expect ( "filter ran" )
601+ }
602+
603+ /// A route whose `authentication:` block names `id-route` (with
604+ /// `replace_inherited`) resolves identity with ONLY that resolver: a
605+ /// request carrying just `X-Route-Token` — no `Authorization` — is
606+ /// accepted. This is the wiring under test: the filter must stamp the
607+ /// route coordinates onto the `Extensions` so the identity hook scopes
608+ /// to the route's `authentication:` list instead of running every
609+ /// registered resolver.
610+ #[ tokio:: test( flavor = "multi_thread" ) ]
611+ async fn route_authentication_scopes_identity_to_its_resolver ( ) {
612+ let ( _dir, path) = write_route_scoped_identity_config ( ) ;
613+ let filter = build_filter ( path) ;
614+ let token = mint_jwt ( & standard_claims ( "alice" ) ) ;
615+
616+ let action = dispatch_tool_with_header ( & filter, "scoped-tool" , "X-Route-Token" , & token) . await ;
617+ assert ! (
618+ !matches!( action, FilterAction :: Reject ( _) ) ,
619+ "scoped-tool must scope to id-route (reads X-Route-Token); a reject means the global \
620+ id-global (reads Authorization, absent here) wrongly ran; got {action:?}",
621+ ) ;
622+ }
623+
624+ /// `replace_inherited` genuinely DROPS the inherited global resolver:
625+ /// `scoped-tool` carrying only `Authorization` (which the global
626+ /// `id-global` reads) and NO `X-Route-Token` is rejected — `id-global`
627+ /// never runs, and the route's `id-route` finds no header. Pins that the
628+ /// scoping EXCLUDES the inherited resolver rather than merely adding the
629+ /// route's on top. (Before the fix, `id-global` ran for every route, so
630+ /// this request would have been accepted.)
631+ #[ tokio:: test( flavor = "multi_thread" ) ]
632+ async fn route_replace_inherited_excludes_global_resolver ( ) {
633+ let ( _dir, path) = write_route_scoped_identity_config ( ) ;
634+ let filter = build_filter ( path) ;
635+ let token = mint_jwt ( & standard_claims ( "alice" ) ) ;
636+
637+ let action = dispatch_tool_with_header ( & filter, "scoped-tool" , "Authorization" , & token) . await ;
638+ assert ! (
639+ matches!( & action, FilterAction :: Reject ( rej) if rej. status == 401 ) ,
640+ "scoped-tool must NOT run the global resolver; Authorization-only must 401; got {action:?}" ,
641+ ) ;
642+ }
643+
644+ /// Control: a route with NO `authentication:` block inherits the global
645+ /// resolver. `open-tool` with only `Authorization` is accepted (global
646+ /// `id-global` runs); with only `X-Route-Token` it is rejected (the
647+ /// route resolver is not in scope here). Together with the scoped-tool
648+ /// cases, this shows the filter selects resolvers PER ROUTE — not one
649+ /// global set for all traffic.
650+ #[ tokio:: test( flavor = "multi_thread" ) ]
651+ async fn route_without_authentication_inherits_global_resolver ( ) {
652+ let ( _dir, path) = write_route_scoped_identity_config ( ) ;
653+ let filter = build_filter ( path) ;
654+ let token = mint_jwt ( & standard_claims ( "alice" ) ) ;
655+
656+ let allowed = dispatch_tool_with_header ( & filter, "open-tool" , "Authorization" , & token) . await ;
657+ assert ! (
658+ !matches!( allowed, FilterAction :: Reject ( _) ) ,
659+ "open-tool inherits id-global (reads Authorization); a reject means id-route \
660+ (reads X-Route-Token, absent here) wrongly ran; got {allowed:?}",
661+ ) ;
662+
663+ let rejected = dispatch_tool_with_header ( & filter, "open-tool" , "X-Route-Token" , & token) . await ;
664+ assert ! (
665+ matches!( & rejected, FilterAction :: Reject ( rej) if rej. status == 401 ) ,
666+ "open-tool does not use the route resolver; X-Route-Token-only must 401; got {rejected:?}" ,
667+ ) ;
668+ }
669+
491670/// Write a CPEX YAML selecting the Valkey-backed session store via a
492671/// flat `global.session_store` block. The `valkey` factory connects
493672/// lazily (the pool dials on first request), so this config loads
0 commit comments