Skip to content

Commit a3334e2

Browse files
committed
feat(apl): evaluate the global policy for entity-less HTTP requests
Make the catch-all `global` policy enforce on generic (non-MCP/A2A) HTTP requests, which carry no entity (Praxis spike Phase B / U3). - New reserved coordinates: ENTITY_HTTP ("http") / ENTITY_NAME_GLOBAL ("*") and the HOOK_CMF_HTTP_REQUEST ("cmf.http_request") hook. - The visitor installs a Pre-phase AplRouteHandler bound to the compiled global policy under those coordinates, granted read_headers so the policy can read the request line/headers. Entity routes still stack `global` via apply_layer; this adds the entity-less evaluation path. - A global-scope `response:` block (transpiled denyWith) is carried onto the global handler and surfaced on deny via PluginViolation.details (U2). A host fires invoke_named::<CmfHook>("cmf.http_request", ...) with meta.entity_type/name set to the reserved coordinates. End-to-end tests cover allow, deny, and custom-denyWith — exercising U1 + U2 + U3 together. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent e725334 commit a3334e2

3 files changed

Lines changed: 194 additions & 6 deletions

File tree

crates/apl-cpex/src/visitor.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ use std::collections::HashMap;
5151
use std::sync::{Arc, RwLock, Weak};
5252

5353
use cpex_core::cmf::constants::{
54-
ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT,
55-
HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE,
56-
HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE,
57-
HOOK_CMF_TOOL_PRE_INVOKE,
54+
ENTITY_HTTP, ENTITY_LLM, ENTITY_NAME_GLOBAL, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL,
55+
HOOK_CMF_HTTP_REQUEST, HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
56+
HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
57+
HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
5858
};
5959
use cpex_core::config::RouteEntry;
6060
use cpex_core::manager::PluginManager;
@@ -357,7 +357,7 @@ impl ConfigVisitor for AplConfigVisitor {
357357

358358
fn visit_global(
359359
&self,
360-
_mgr: &Arc<PluginManager>,
360+
mgr: &Arc<PluginManager>,
361361
yaml: &serde_yaml::Value,
362362
) -> Result<(), VisitorError> {
363363
let Some(apl_block) = apl_subblock(yaml) else {
@@ -386,8 +386,50 @@ impl ConfigVisitor for AplConfigVisitor {
386386
// `post_policy:` / `args:` / `result:` / `plugins:` (and inert
387387
// fields it ignores), so a shallow strip on a clone is enough.
388388
let policy_only = strip_non_dsl_keys(&apl_block);
389-
let compiled = compile_policy_block_value("global.apl", &policy_only)
389+
let mut compiled = compile_policy_block_value("global.apl", &policy_only)
390390
.map_err(|e| Box::new(e) as VisitorError)?;
391+
// A `response:` block at the global scope is the catch-all denyWith.
392+
compiled.response = response_subblock(yaml, "global");
393+
394+
// Install a catch-all handler so the global policy also evaluates for
395+
// generic (non-MCP/A2A) HTTP requests, which carry no entity (U3).
396+
// Entity routes still stack `global` via apply_layer in visit_route;
397+
// this is the *entity-less* evaluation path. Pre-phase only —
398+
// authorization is an admission check, so there is no post handler.
399+
if !compiled.policy.is_empty() {
400+
let (plugin_registry, pdp_router_arc) = {
401+
let state = self.state.read().unwrap_or_else(|p| p.into_inner());
402+
(
403+
Arc::new(state.plugin_registry.clone()),
404+
Arc::new(state.pdp_router.clone()) as Arc<dyn PdpResolver>,
405+
)
406+
};
407+
let session_store = self
408+
.session_store
409+
.read()
410+
.unwrap_or_else(|p| p.into_inner())
411+
.clone();
412+
// The global HTTP policy reads the request line / headers, so
413+
// grant `read_headers` on top of the visitor baseline.
414+
let mut caps = self.base_capabilities.clone();
415+
caps.insert("read_headers".to_string());
416+
install_handler(
417+
mgr,
418+
ENTITY_HTTP,
419+
ENTITY_NAME_GLOBAL,
420+
None,
421+
HOOK_CMF_HTTP_REQUEST,
422+
Phase::Pre,
423+
Arc::new(compiled.clone()),
424+
&plugin_registry,
425+
&self.dispatch_cache,
426+
&session_store,
427+
&self.manager,
428+
Some(pdp_router_arc),
429+
&caps,
430+
);
431+
}
432+
391433
self.state
392434
.write()
393435
.unwrap_or_else(|p| p.into_inner())
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Location: ./crates/apl-cpex/tests/global_http_authz.rs
2+
// Copyright 2026
3+
// SPDX-License-Identifier: Apache-2.0
4+
// Authors: Fred Araujo
5+
//
6+
// End-to-end: a `global` APL policy is evaluated for a generic
7+
// (non-MCP/A2A) HTTP request that carries no entity. The visitor installs
8+
// a catch-all handler under (ENTITY_HTTP, ENTITY_NAME_GLOBAL,
9+
// HOOK_CMF_HTTP_REQUEST); the host fires that hook with `meta` set to the
10+
// reserved coordinates and an `http` extension carrying the request line.
11+
// This is the entity-less authorization path the Praxis AuthPolicy
12+
// transpiler targets (spike Phase B / U3). It also exercises U1
13+
// (http.method in the bag) and U2 (custom denyWith via the route
14+
// `response:` block surfaced on the violation details).
15+
16+
use std::sync::Arc;
17+
18+
use cpex_core::cmf::constants::{ENTITY_HTTP, ENTITY_NAME_GLOBAL, HOOK_CMF_HTTP_REQUEST};
19+
use cpex_core::cmf::enums::Role;
20+
use cpex_core::cmf::{CmfHook, Message, MessagePayload};
21+
use cpex_core::extensions::{Extensions, HttpExtension, MetaExtension};
22+
use cpex_core::manager::PluginManager;
23+
24+
use apl_cpex::{register_apl, AplOptions};
25+
26+
async fn manager_with(yaml: &str) -> Arc<PluginManager> {
27+
let mgr = Arc::new(PluginManager::default());
28+
register_apl(&mgr, AplOptions::in_process());
29+
mgr.load_config_yaml(yaml).expect("load_config_yaml");
30+
mgr.initialize().await.expect("initialize");
31+
mgr
32+
}
33+
34+
/// A generic-HTTP request: reserved entity coordinates + an `http`
35+
/// extension carrying the request method.
36+
fn http_request(method: &str) -> Extensions {
37+
let mut meta = MetaExtension::default();
38+
meta.entity_type = Some(ENTITY_HTTP.to_string());
39+
meta.entity_name = Some(ENTITY_NAME_GLOBAL.to_string());
40+
let http = HttpExtension {
41+
method: Some(method.to_string()),
42+
..Default::default()
43+
};
44+
Extensions {
45+
meta: Some(Arc::new(meta)),
46+
http: Some(Arc::new(http)),
47+
..Default::default()
48+
}
49+
}
50+
51+
fn payload() -> MessagePayload {
52+
MessagePayload {
53+
message: Message::text(Role::User, "hi"),
54+
}
55+
}
56+
57+
// APL predicate:action form: deny when the method is not GET. (Comparisons
58+
// use this form; `require(...)` is truthiness-only.)
59+
const GET_ONLY: &str = r#"
60+
plugin_settings:
61+
routing_enabled: true
62+
global:
63+
apl:
64+
policy:
65+
- "http.method != 'GET': deny"
66+
"#;
67+
68+
#[tokio::test]
69+
async fn global_policy_allows_matching_http_request() {
70+
let mgr = manager_with(GET_ONLY).await;
71+
let (res, _bg) = mgr
72+
.invoke_named::<CmfHook>(HOOK_CMF_HTTP_REQUEST, payload(), http_request("GET"), None)
73+
.await;
74+
assert!(
75+
res.continue_processing,
76+
"GET must be allowed by the global policy; violation = {:?}",
77+
res.violation
78+
);
79+
}
80+
81+
#[tokio::test]
82+
async fn global_policy_denies_nonmatching_http_request() {
83+
let mgr = manager_with(GET_ONLY).await;
84+
let (res, _bg) = mgr
85+
.invoke_named::<CmfHook>(HOOK_CMF_HTTP_REQUEST, payload(), http_request("POST"), None)
86+
.await;
87+
assert!(
88+
!res.continue_processing,
89+
"POST must be denied by the global policy"
90+
);
91+
}
92+
93+
/// A route-level `response:` block (transpiled `denyWith`) surfaces custom
94+
/// status/body/headers on the violation `details` map (U2) when the global
95+
/// policy denies.
96+
#[tokio::test]
97+
async fn global_policy_deny_carries_custom_response() {
98+
const YAML: &str = r#"
99+
plugin_settings:
100+
routing_enabled: true
101+
global:
102+
apl:
103+
policy:
104+
- "http.method != 'GET': deny"
105+
response:
106+
status: 403
107+
body: "{\"error\":\"forbidden\"}"
108+
headers:
109+
X-Reason: "method-not-allowed"
110+
"#;
111+
let mgr = manager_with(YAML).await;
112+
let (res, _bg) = mgr
113+
.invoke_named::<CmfHook>(
114+
HOOK_CMF_HTTP_REQUEST,
115+
payload(),
116+
http_request("DELETE"),
117+
None,
118+
)
119+
.await;
120+
assert!(!res.continue_processing, "DELETE must be denied");
121+
let v = res.violation.expect("deny must surface a violation");
122+
assert_eq!(v.details.get("http.status"), Some(&serde_json::json!(403)));
123+
assert_eq!(
124+
v.details.get("http.body"),
125+
Some(&serde_json::json!("{\"error\":\"forbidden\"}"))
126+
);
127+
assert_eq!(
128+
v.details.get("http.headers"),
129+
Some(&serde_json::json!({ "X-Reason": "method-not-allowed" }))
130+
);
131+
}

crates/cpex-core/src/cmf/constants.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ pub const ENTITY_LLM: &str = "llm";
7676
pub const ENTITY_PROMPT: &str = "prompt";
7777
pub const ENTITY_RESOURCE: &str = "resource";
7878

79+
/// Reserved entity type for generic (non-MCP/A2A) HTTP requests. The
80+
/// catch-all `global` policy is dispatched under this entity so an
81+
/// entity-less request can be authorized; hosts set `meta.entity_type` to
82+
/// this and `meta.entity_name` to [`ENTITY_NAME_GLOBAL`].
83+
pub const ENTITY_HTTP: &str = "http";
84+
85+
/// Reserved entity name for the global catch-all policy annotation.
86+
pub const ENTITY_NAME_GLOBAL: &str = "*";
87+
7988
// ---------------------------------------------------------------------------
8089
// CMF hook names — the canonical names plugins register under and hosts
8190
// pass to `PluginManager::invoke_named::<CmfHook>(...)`. Two per entity
@@ -94,3 +103,9 @@ pub const HOOK_CMF_PROMPT_PRE_INVOKE: &str = "cmf.prompt_pre_invoke";
94103
pub const HOOK_CMF_PROMPT_POST_INVOKE: &str = "cmf.prompt_post_invoke";
95104
pub const HOOK_CMF_RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch";
96105
pub const HOOK_CMF_RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch";
106+
107+
/// Generic HTTP request hook. Hosts fire this for non-MCP/A2A HTTP
108+
/// requests; the catch-all `global` policy (if any) is annotated under
109+
/// it via [`ENTITY_HTTP`] / [`ENTITY_NAME_GLOBAL`]. Pre-invocation only —
110+
/// authorization is an admission check, so there is no post counterpart.
111+
pub const HOOK_CMF_HTTP_REQUEST: &str = "cmf.http_request";

0 commit comments

Comments
 (0)