Skip to content

Commit 4608522

Browse files
committed
fix(apl): close fail-open gaps in entity-less HTTP catch-all
Gate the catch-all handler install on args OR policy (not policy alone), so an args-only global.apl still authorizes entity-less HTTP traffic. Warn when a global response: is configured but no installable policy exists, including the bare response-only block that hit visit_global's early return. Accept response: nested under apl: as well as top-level, with top-level taking precedence (documented as deliberate). Cover the fail-closed session-store denials and the new paths with tests. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 36820f0 commit 4608522

3 files changed

Lines changed: 310 additions & 8 deletions

File tree

crates/apl-cpex/src/visitor.rs

Lines changed: 169 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,19 @@ impl ConfigVisitor for AplConfigVisitor {
389389
yaml: &serde_yaml::Value,
390390
) -> Result<(), VisitorError> {
391391
let Some(apl_block) = apl_subblock(yaml) else {
392+
// No `apl:` wrapper and no flat DSL keys — there is nothing to
393+
// compile or install. But a bare `global: { response: {...} }`
394+
// (a denyWith with no accompanying policy) would otherwise be
395+
// dropped here silently, before the `response_subblock` read
396+
// below ever runs. Warn so this fail-open-by-omission case gets
397+
// the same signal as the args/policy-empty case handled further
398+
// down, rather than vanishing without a trace.
399+
if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) {
400+
tracing::warn!(
401+
"APL visitor: global.response is set but global.apl has no policy/args block \
402+
— the entity-less HTTP catch-all handler will not install, so this response can never fire",
403+
);
404+
}
392405
return Ok(());
393406
};
394407

@@ -424,7 +437,14 @@ impl ConfigVisitor for AplConfigVisitor {
424437
// Entity routes still stack `global` via apply_layer in visit_route;
425438
// this is the *entity-less* evaluation path. Pre-phase only —
426439
// authorization is an admission check, so there is no post handler.
427-
if !compiled.policy.is_empty() {
440+
let installs_pre_handler = http_catchall_should_install(&compiled);
441+
if !installs_pre_handler && compiled.response.is_some() {
442+
tracing::warn!(
443+
"APL visitor: global.response is set but global.apl has no `args:`/`policy:` steps \
444+
— the entity-less HTTP catch-all handler will not install, so this response can never fire",
445+
);
446+
}
447+
if installs_pre_handler {
428448
let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state();
429449
// The global HTTP policy reads the request line / headers, so
430450
// grant `read_headers` on top of the visitor baseline.
@@ -938,26 +958,61 @@ fn apl_subblock(yaml: &serde_yaml::Value) -> Option<serde_yaml::Value> {
938958
}
939959
}
940960

941-
/// Extract a route-level `response:` block — the transpiled `denyWith`.
942-
/// cpex-core tolerates this out-of-band key on the route; here we
943-
/// deserialize it into a [`DenyResponse`]. A malformed block is logged
944-
/// and skipped (best-effort) rather than failing the whole config.
961+
/// Whether the entity-less HTTP catch-all handler (Pre-phase only) should
962+
/// install for a compiled `global` layer. Gate on both Pre-phase steps
963+
/// (`args` + `policy`, via [`CompiledRoute::declared_phases`]), not
964+
/// `policy` alone — an operator whose `global.apl` has only an `args:`
965+
/// admission block (no `policy:`) must still get the catch-all installed,
966+
/// or entity-less HTTP traffic silently bypasses it entirely (fail-open by
967+
/// omission).
968+
fn http_catchall_should_install(compiled: &CompiledRoute) -> bool {
969+
let declared = compiled.declared_phases();
970+
declared.contains(apl_core::rules::Phase::Args)
971+
|| declared.contains(apl_core::rules::Phase::Policy)
972+
}
973+
974+
/// `response:` is not an APL DSL term (it never enters [`apl_subblock`]'s
975+
/// [`FLAT_APL_KEYS`]) — it is documented and tested as a sibling of `apl:`
976+
/// (`global: { apl: {...}, response: {...} }`). But an operator who mirrors
977+
/// the `pdp:` / `session_store:` convention (which *do* work identically
978+
/// whether flat or nested under `apl:`) may reasonably nest `response:`
979+
/// inside `apl:` too. Accept both spellings so that mistake degrades to
980+
/// "the other spelling wins," not "silently dropped."
981+
///
982+
/// PRECEDENCE — deliberately the INVERSE of [`apl_subblock`]. `apl_subblock`
983+
/// makes an explicit `apl:` wrapper win *entirely* over flat top-level keys
984+
/// (for `policy:`/`pdp:`/`session_store:`); here the top-level sibling
985+
/// `response:` wins over an `apl:`-nested one. This is intentional, not an
986+
/// oversight: the top-level sibling is the documented, already-shipped,
987+
/// tested form, so preferring it preserves backward compatibility, and the
988+
/// choice can only affect the *rendered denial shape* (status/body/headers)
989+
/// — never an Allow/Deny outcome. Do NOT "align" this with `apl_subblock`'s
990+
/// wrapper-wins rule without a deliberate compatibility decision.
991+
fn response_yaml_block(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> {
992+
yaml.get("response")
993+
.or_else(|| yaml.get("apl").and_then(|apl| apl.get("response")))
994+
}
995+
945996
/// Warn when a `response:` block appears at a scope that never renders it.
946997
/// A custom denial response is honored only at `global` (the entity-less
947998
/// HTTP path) or on a route; at `default` / policy-bundle scope it is inert
948999
/// — there is no propagation path to a handler. Mirrors the existing
9491000
/// global-only-key lint so a misplaced `response:` fails loud, not silent.
9501001
fn warn_if_response_at_unsupported_scope(yaml: &serde_yaml::Value, scope: &str) {
951-
if yaml.get("response").is_some_and(|v| !v.is_null()) {
1002+
if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) {
9521003
tracing::warn!(
9531004
scope,
9541005
"APL visitor: `response:` is honored only at `global` or route scope; ignoring here",
9551006
);
9561007
}
9571008
}
9581009

1010+
/// Extract a route-level `response:` block — the transpiled `denyWith`.
1011+
/// cpex-core tolerates this out-of-band key on the route; here we
1012+
/// deserialize it into a [`DenyResponse`]. A malformed block is logged
1013+
/// and skipped (best-effort) rather than failing the whole config.
9591014
fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option<DenyResponse> {
960-
let block = yaml.get("response")?;
1015+
let block = response_yaml_block(yaml)?;
9611016
if block.is_null() {
9621017
return None;
9631018
}
@@ -972,12 +1027,70 @@ fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option<DenyRe
9721027

9731028
#[cfg(test)]
9741029
mod tests {
975-
use super::{apl_subblock, response_subblock};
1030+
use super::{apl_subblock, http_catchall_should_install, response_subblock};
1031+
use apl_core::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
1032+
use apl_core::rules::{CompiledRoute, Effect};
9761033

9771034
fn yaml(s: &str) -> serde_yaml::Value {
9781035
serde_yaml::from_str(s).expect("valid yaml")
9791036
}
9801037

1038+
fn deny_effect() -> Effect {
1039+
Effect::Deny {
1040+
reason: None,
1041+
code: None,
1042+
}
1043+
}
1044+
1045+
fn field_rule(field: &str) -> FieldRule {
1046+
FieldRule {
1047+
field: field.to_string(),
1048+
pipeline: Pipeline {
1049+
stages: vec![Stage::Type(TypeCheck::Str)],
1050+
},
1051+
source: "test".to_string(),
1052+
}
1053+
}
1054+
1055+
#[test]
1056+
fn http_catchall_installs_for_args_only_global_block() {
1057+
// Regression for the fail-open-by-omission gap: a `global.apl` with
1058+
// only `args:` (no `policy:`) must still get the entity-less HTTP
1059+
// catch-all installed. Before the fix this gated on
1060+
// `!compiled.policy.is_empty()` alone, so an args-only admission
1061+
// block silently disabled authorization for all entity-less HTTP
1062+
// traffic.
1063+
let mut route = CompiledRoute::new("global");
1064+
route.args.push(field_rule("http.method"));
1065+
assert!(
1066+
http_catchall_should_install(&route),
1067+
"an args-only global block must still install the catch-all handler"
1068+
);
1069+
}
1070+
1071+
#[test]
1072+
fn http_catchall_installs_for_policy_only_global_block() {
1073+
let mut route = CompiledRoute::new("global");
1074+
route.policy.push(deny_effect());
1075+
assert!(http_catchall_should_install(&route));
1076+
}
1077+
1078+
#[test]
1079+
fn http_catchall_does_not_install_for_empty_or_post_only_global_block() {
1080+
let empty = CompiledRoute::new("global");
1081+
assert!(
1082+
!http_catchall_should_install(&empty),
1083+
"an empty global block has nothing to evaluate; installing would be a no-op handler"
1084+
);
1085+
1086+
let mut post_only = CompiledRoute::new("global");
1087+
post_only.post_policy.push(deny_effect());
1088+
assert!(
1089+
!http_catchall_should_install(&post_only),
1090+
"post_policy never runs on the Pre-phase-only catch-all, so it must not gate installation"
1091+
);
1092+
}
1093+
9811094
#[test]
9821095
fn response_subblock_parses_denywith() {
9831096
let v = yaml(
@@ -998,6 +1111,54 @@ mod tests {
9981111
assert!(response_subblock(&v, "tool:*").is_none());
9991112
}
10001113

1114+
#[test]
1115+
fn response_subblock_nested_under_apl_wrapper_is_read() {
1116+
// An operator mirroring the pdp:/session_store: convention (which
1117+
// work identically flat or nested under `apl:`) may nest `response:`
1118+
// under `apl:` too. It must not be silently absorbed.
1119+
let v =
1120+
yaml("tool: \"*\"\napl:\n policy:\n - \"deny\"\n response:\n status: 401\n");
1121+
let resp = response_subblock(&v, "tool:*").expect("nested response present");
1122+
assert_eq!(resp.status, Some(401));
1123+
}
1124+
1125+
#[test]
1126+
fn response_subblock_top_level_wins_over_nested_apl_form() {
1127+
let v = yaml(
1128+
"tool: \"*\"\napl:\n policy:\n - \"deny\"\n response:\n status: 401\nresponse:\n status: 403\n",
1129+
);
1130+
let resp = response_subblock(&v, "tool:*").expect("response present");
1131+
assert_eq!(
1132+
resp.status,
1133+
Some(403),
1134+
"top-level sibling response takes precedence over the nested apl: form"
1135+
);
1136+
}
1137+
1138+
#[test]
1139+
fn response_subblock_malformed_is_none_not_propagated() {
1140+
// `status` must deserialize as a u16; a string value fails to parse.
1141+
// A malformed block must be dropped (warn-only), never bubble up an
1142+
// error that fails the whole config load.
1143+
let v = yaml("tool: \"*\"\nresponse:\n status: \"not-a-number\"\n");
1144+
assert!(
1145+
response_subblock(&v, "tool:*").is_none(),
1146+
"malformed response: block must be ignored, not panic or propagate an error"
1147+
);
1148+
}
1149+
1150+
#[test]
1151+
fn warn_if_response_at_unsupported_scope_is_a_safe_noop() {
1152+
use super::warn_if_response_at_unsupported_scope;
1153+
// The helper only emits a tracing event; it must never panic whether
1154+
// `response:` is present or absent at a scope that can't render it.
1155+
let with_response = yaml("policy:\n - \"deny\"\nresponse:\n status: 403\n");
1156+
let without = yaml("policy:\n - \"deny\"\n");
1157+
warn_if_response_at_unsupported_scope(&with_response, "global.defaults.tool");
1158+
warn_if_response_at_unsupported_scope(&with_response, "global.policies.some-tag");
1159+
warn_if_response_at_unsupported_scope(&without, "global.defaults.tool");
1160+
}
1161+
10011162
#[test]
10021163
fn apl_wrapper_is_returned_as_is() {
10031164
let v = yaml("apl:\n policy:\n - \"deny\"\n");

crates/apl-cpex/tests/end_to_end_route.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,111 @@ async fn append_failure_fails_request_closed() {
752752
);
753753
}
754754

755+
// Same tagger route as `TAGGER_ROUTE_YAML`, but with a route-level
756+
// `response:` block — proves the fail-closed session-store denials
757+
// (`session.load_failed` / `session.persist_failed`) decorate their
758+
// violation with the route's custom denyWith too, not just an ordinary
759+
// `Decision::Deny`.
760+
const TAGGER_ROUTE_WITH_RESPONSE_YAML: &str = r#"
761+
plugins:
762+
- name: tagger
763+
kind: tagger
764+
hooks: [cmf.tool_pre_invoke]
765+
capabilities: [append_labels, read_labels]
766+
routes:
767+
- tool: get_weather
768+
apl:
769+
policy:
770+
- "plugin(tagger)"
771+
response:
772+
status: 503
773+
body: "session unavailable"
774+
"#;
775+
776+
async fn tagger_manager_with_store_and_yaml(
777+
store: Arc<dyn SessionStore>,
778+
yaml: &str,
779+
) -> Arc<PluginManager> {
780+
let mgr = Arc::new(PluginManager::default());
781+
mgr.register_factory("tagger", Box::new(TaintingPluginFactory));
782+
register_apl(
783+
&mgr,
784+
AplOptions {
785+
dispatch_cache: Arc::new(DispatchCache::new()),
786+
session_store: store,
787+
pdps: Vec::new(),
788+
pdp_factories: Vec::new(),
789+
session_store_factories: Vec::new(),
790+
base_capabilities: None,
791+
},
792+
);
793+
mgr.load_config_yaml(yaml).expect("load_config_yaml");
794+
mgr.initialize().await.expect("initialize");
795+
mgr
796+
}
797+
798+
/// A `session.load_failed` denial (AE1) still carries the route's custom
799+
/// `response:` (denyWith) on its `details` map — the fix that closed prior
800+
/// review gap #3 must hold for the load-failure fail-closed path, not just
801+
/// `Decision::Deny`.
802+
#[tokio::test]
803+
async fn load_failure_carries_route_response() {
804+
let store: Arc<dyn SessionStore> = Arc::new(ErrorSessionStore {
805+
fail_load: true,
806+
fail_append: false,
807+
});
808+
let mgr = tagger_manager_with_store_and_yaml(store, TAGGER_ROUTE_WITH_RESPONSE_YAML).await;
809+
let (mut ext, _key) = session_ext_and_key("sess-load-fail-resp", "alice");
810+
set_tool_meta(&mut ext, "get_weather");
811+
812+
let (result, _bg) = mgr
813+
.invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload(), ext, None)
814+
.await;
815+
816+
assert!(!result.continue_processing);
817+
let violation = result
818+
.violation
819+
.expect("load failure must surface a violation");
820+
assert_eq!(violation.code, "session.load_failed");
821+
assert_eq!(
822+
violation
823+
.details
824+
.get(apl_cmf::constants::DETAIL_HTTP_STATUS),
825+
Some(&serde_json::json!(503)),
826+
"load_failed denial must carry the route's custom response status"
827+
);
828+
}
829+
830+
/// A `session.persist_failed` denial (AE6, R18) still carries the route's
831+
/// custom `response:` (denyWith) on its `details` map.
832+
#[tokio::test]
833+
async fn persist_failure_carries_route_response() {
834+
let store: Arc<dyn SessionStore> = Arc::new(ErrorSessionStore {
835+
fail_load: false,
836+
fail_append: true,
837+
});
838+
let mgr = tagger_manager_with_store_and_yaml(store, TAGGER_ROUTE_WITH_RESPONSE_YAML).await;
839+
let (mut ext, _key) = session_ext_and_key("sess-append-fail-resp", "alice");
840+
set_tool_meta(&mut ext, "get_weather");
841+
842+
let (result, _bg) = mgr
843+
.invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload(), ext, None)
844+
.await;
845+
846+
assert!(!result.continue_processing);
847+
let violation = result
848+
.violation
849+
.expect("append failure must flip to a Deny with a violation");
850+
assert_eq!(violation.code, "session.persist_failed");
851+
assert_eq!(
852+
violation
853+
.details
854+
.get(apl_cmf::constants::DETAIL_HTTP_STATUS),
855+
Some(&serde_json::json!(503)),
856+
"persist_failed denial must carry the route's custom response status"
857+
);
858+
}
859+
755860
/// R18 merge precedence: when the policy already Denies AND the append
756861
/// fails, the original policy violation is preserved (not overwritten by
757862
/// `session.persist_failed`) — the request is already denied, so the

crates/apl-cpex/tests/visitor_e2e.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,42 @@ routes:
435435
assert!(result.violation.is_none());
436436
}
437437

438+
/// A bare `global: { response: {...} }` — a denyWith with no accompanying
439+
/// `apl:` policy/args block — must load cleanly (the visitor warns and moves
440+
/// on) rather than panicking or erroring. `visit_global` returns early when
441+
/// `apl_subblock` finds no APL terms; this guards that the stranded
442+
/// `response:` on that early-return path is handled, not silently exploded.
443+
#[tokio::test]
444+
async fn global_response_without_apl_block_loads_without_error() {
445+
const YAML: &str = r#"
446+
plugins:
447+
- name: allow-gate
448+
kind: allow-gate
449+
hooks: [cmf.tool_pre_invoke]
450+
global:
451+
response:
452+
status: 403
453+
body: "forbidden"
454+
routes:
455+
- tool: anything
456+
"#;
457+
// The load must not panic or return Err despite the response-only global
458+
// block having no installable policy. A request still flows through the
459+
// legacy chain (no catch-all handler was installed for the entity-less
460+
// path, which is the documented behavior this warns about).
461+
let mgr = build_manager_with_visitor(YAML).await;
462+
463+
let ext = Extensions {
464+
meta: Some(Arc::new(meta_for_tool("anything"))),
465+
..Default::default()
466+
};
467+
let (result, _bg) = mgr
468+
.invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None)
469+
.await;
470+
assert!(result.continue_processing);
471+
assert!(result.violation.is_none());
472+
}
473+
438474
/// Smoke test that the visitor surfaces a compile error from a malformed
439475
/// APL block as a `PluginError::Config` out of `load_config_yaml`. Catches
440476
/// regressions where visitor errors swallow into Ok(_) or panic.

0 commit comments

Comments
 (0)