Skip to content

Commit d151e84

Browse files
authored
fix(policy): honor route-scoped identity inside policy engine. (#912)
fix(policy): honor route-scoped identity instead of running every resolver Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent 7292d91 commit d151e84

2 files changed

Lines changed: 201 additions & 8 deletions

File tree

filter/src/builtins/http/security/policy/filter.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use cpex::cpex_core::{
1818
},
1919
},
2020
error::{PluginError, PluginViolation},
21+
extensions::MetaExtension,
2122
hooks::Extensions,
2223
identity::{HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource},
2324
manager::PluginManager,
@@ -266,18 +267,28 @@ impl PolicyFilter {
266267
/// resolved [`IdentityPayload`] (subject / client / workload / raw
267268
/// credentials / delegation) or a rejection when no identity
268269
/// continues. Cheap — the JWT verifier hits its in-process key cache.
270+
#[expect(clippy::large_stack_frames, reason = "async handler over large CMF/pipeline types")]
269271
async fn resolve_identity(
270272
&self,
271273
headers: std::collections::HashMap<String, String>,
274+
entity_type: &str,
275+
entity_name: &str,
272276
) -> Result<IdentityPayload, Rejection> {
277+
// Route coordinates must be on the Extensions or the identity hook
278+
// can't tell which route this is and silently runs every registered
279+
// resolver instead of the route's `authentication:` list.
280+
let route_ext = Extensions {
281+
meta: Some(Arc::new(MetaExtension {
282+
entity_type: Some(entity_type.to_owned()),
283+
entity_name: Some(entity_name.to_owned()),
284+
..Default::default()
285+
})),
286+
..Default::default()
287+
};
288+
273289
let (id_result, _bg) = self
274290
.mgr
275-
.invoke_named::<IdentityHook>(
276-
HOOK_IDENTITY_RESOLVE,
277-
Self::identity_payload(headers),
278-
Extensions::default(),
279-
None,
280-
)
291+
.invoke_named::<IdentityHook>(HOOK_IDENTITY_RESOLVE, Self::identity_payload(headers), route_ext, None)
281292
.await;
282293
if !id_result.continue_processing {
283294
return Err(auth_rejection(id_result.violation.as_ref()));
@@ -371,7 +382,10 @@ impl PolicyFilter {
371382
use cpex::cpex_core::cmf::constants::{ENTITY_HTTP, ENTITY_NAME_GLOBAL};
372383

373384
let headers = Self::snapshot_headers(ctx);
374-
let identity = match self.resolve_identity(headers.clone()).await {
385+
let identity = match self
386+
.resolve_identity(headers.clone(), ENTITY_HTTP, ENTITY_NAME_GLOBAL)
387+
.await
388+
{
375389
Ok(id) => id,
376390
Err(rej) => return Ok(FilterAction::Reject(rej)),
377391
};
@@ -599,7 +613,7 @@ impl HttpFilter for PolicyFilter {
599613

600614
// Resolve identity once here, then stash it so the response phase
601615
// can rebuild `Extensions` without re-validating the token.
602-
let identity = match self.resolve_identity(headers.clone()).await {
616+
let identity = match self.resolve_identity(headers.clone(), entity_type, &entity_name).await {
603617
Ok(id) => id,
604618
Err(rej) => return Ok(FilterAction::Reject(rej)),
605619
};

filter/src/builtins/http/security/policy/tests.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)