@@ -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.
9501001fn 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.
9591014fn 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) ]
9741029mod 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: \" *\" \n apl:\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: \" *\" \n apl:\n policy:\n - \" deny\" \n response:\n status: 401\n response:\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: \" *\" \n response:\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\" \n response:\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 " ) ;
0 commit comments