use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use cpex_core::context::PluginContext;
use cpex_core::delegation::{DelegationPayload, TokenDelegateHook, HOOK_TOKEN_DELEGATE};
use cpex_core::error::PluginViolation;
use cpex_core::extensions::raw_credentials::{
RawCredentialsExtension, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
};
use cpex_core::hooks::payload::Extensions;
use cpex_core::hooks::trait_def::{HookHandler, PluginResult};
use cpex_core::manager::PluginManager;
use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode};
use apl_core::{
compile_config, evaluate_route, AttributeBag, Decision, PdpCall, PdpDecision, PdpDialect,
PdpError, PdpResolver, RoutePayload,
};
use apl_cpex::{
CmfPluginInvoker, DelegationPluginInvoker, DispatchCache, MemorySessionStore, SessionStore,
};
#[derive(Debug, Clone)]
struct DelegateCallRecord {
plugin_name: String,
target_name: String,
target_audience: Option<String>,
required_permissions: Vec<String>,
}
struct RecordingDelegate {
cfg: PluginConfig,
ledger: Arc<Mutex<Vec<DelegateCallRecord>>>,
grant_scopes: Option<Vec<String>>,
grant_audience: String,
deny_code: Option<String>,
observed_extensions: Arc<Mutex<Option<ExtensionsObservation>>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ExtensionsObservation {
saw_subject_id: Option<String>,
saw_labels: Vec<String>,
saw_inbound_token_for_user: bool,
saw_delegation_chain_present: bool,
}
#[async_trait]
impl Plugin for RecordingDelegate {
fn config(&self) -> &PluginConfig {
&self.cfg
}
}
impl HookHandler<TokenDelegateHook> for RecordingDelegate {
async fn handle(
&self,
payload: &DelegationPayload,
ext: &Extensions,
_ctx: &mut PluginContext,
) -> PluginResult<DelegationPayload> {
self.ledger.lock().unwrap().push(DelegateCallRecord {
plugin_name: self.cfg.name.clone(),
target_name: payload.target_name().to_string(),
target_audience: payload.target_audience().map(str::to_string),
required_permissions: payload.required_permissions().to_vec(),
});
*self.observed_extensions.lock().unwrap() = Some(ExtensionsObservation {
saw_subject_id: ext
.security
.as_ref()
.and_then(|s| s.subject.as_ref())
.and_then(|s| s.id.clone()),
saw_labels: ext
.security
.as_ref()
.map(|s| s.labels.iter().cloned().collect())
.unwrap_or_default(),
saw_inbound_token_for_user: ext
.raw_credentials
.as_ref()
.map(|rc| rc.inbound_tokens.contains_key(&TokenRole::User))
.unwrap_or(false),
saw_delegation_chain_present: ext.delegation.is_some(),
});
if let Some(code) = &self.deny_code {
return PluginResult::deny(PluginViolation::new(
code.clone(),
format!("recording-delegate `{}` denied", self.cfg.name),
));
}
let scopes = self.grant_scopes.clone().unwrap_or_default();
let token = RawDelegatedToken::new(
format!("fake.token.for.{}", self.cfg.name),
"Authorization",
self.grant_audience.clone(),
scopes,
Utc::now() + Duration::seconds(300),
);
let mut updated = payload.clone();
updated.delegated_token = Some(token);
PluginResult::modify_payload(updated)
}
}
fn delegate_cfg(name: &str) -> PluginConfig {
delegate_cfg_with_caps(name, &[])
}
fn delegate_cfg_with_caps(name: &str, caps: &[&str]) -> PluginConfig {
PluginConfig {
name: name.to_string(),
kind: "test".to_string(),
description: None,
author: None,
version: None,
hooks: vec![HOOK_TOKEN_DELEGATE.to_string()],
mode: PluginMode::Sequential,
priority: 10,
on_error: OnError::Fail,
capabilities: caps.iter().map(|s| s.to_string()).collect(),
tags: Vec::new(),
conditions: Vec::new(),
config: None,
}
}
struct AllowPdp;
#[async_trait]
impl PdpResolver for AllowPdp {
fn dialect(&self) -> PdpDialect {
PdpDialect::Cedar
}
async fn evaluate(
&self,
_call: &PdpCall,
_bag: &AttributeBag,
) -> Result<PdpDecision, PdpError> {
Ok(PdpDecision {
decision: Decision::Allow,
diagnostics: vec![],
})
}
}
fn ext_with_bearer(token: &str) -> Extensions {
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
);
Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
}
}
fn ext_with_subject_and_label(token: &str, subject_id: &str, label: &str) -> Extensions {
use cpex_core::extensions::{SecurityExtension, SubjectExtension};
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
);
let mut sec = SecurityExtension::default();
sec.subject = Some(SubjectExtension {
id: Some(subject_id.to_string()),
..Default::default()
});
sec.add_label(label);
Extensions {
raw_credentials: Some(Arc::new(raw)),
security: Some(Arc::new(sec)),
..Default::default()
}
}
async fn build_setup(
yaml: &str,
plugins: Vec<(String, Arc<RecordingDelegate>, PluginConfig)>,
) -> (
Arc<PluginManager>,
apl_core::CompiledConfig,
Arc<DispatchCache>,
) {
let mgr = Arc::new(PluginManager::default());
for (_, plugin, cfg) in plugins {
mgr.register_handler::<TokenDelegateHook, _>(plugin, cfg)
.expect("register delegate plugin");
}
mgr.initialize().await.expect("initialize");
let cfg = compile_config(yaml).expect("compile route YAML");
let cache = Arc::new(DispatchCache::new());
(mgr, cfg, cache)
}
#[tokio::test]
async fn delegate_step_grants_visible_to_downstream_require() {
let ledger: Arc<Mutex<Vec<DelegateCallRecord>>> = Arc::new(Mutex::new(Vec::new()));
let plugin = Arc::new(RecordingDelegate {
cfg: delegate_cfg("workday-oauth"),
ledger: Arc::clone(&ledger),
grant_scopes: Some(vec!["read_compensation".to_string()]),
grant_audience: "workday-api".to_string(),
deny_code: None,
observed_extensions: Arc::new(Mutex::new(None)),
});
let yaml = r#"
plugins:
- name: workday-oauth
kind: test
hooks: [token.delegate]
routes:
get_compensation:
policy:
- "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
- "!delegation.granted: deny"
- "!(delegation.granted.permissions contains 'read_compensation'): deny"
"#;
let (mgr, cfg, cache) = build_setup(
yaml,
vec![(
"workday-oauth".to_string(),
Arc::clone(&plugin),
delegate_cfg("workday-oauth"),
)],
)
.await;
let route = cfg.routes.get("get_compensation").expect("route present");
let registry = cfg.plugins.clone();
let plan = cache.get_or_build(route, ®istry, &mgr).await;
let extensions = ext_with_bearer("eyJ.fake.user-jwt");
let session_store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let invoker = Arc::new(
CmfPluginInvoker::for_request(
Arc::clone(&mgr),
extensions,
cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(
cpex_core::cmf::enums::Role::User,
"fetch compensation",
),
},
Arc::clone(&plan),
Arc::clone(&session_store),
)
.await
.expect("for_request"),
);
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&mgr),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let mut bag = apl_cmf::BagBuilder::new()
.with_extensions(&invoker.current_extensions().await)
.with_route_key(&route.route_key)
.build();
let mut payload = RoutePayload::new(serde_json::Value::Null);
let decision = evaluate_route(
route,
&mut bag,
&mut payload,
&(Arc::new(AllowPdp) as Arc<dyn apl_core::PdpResolver>),
&(invoker.clone() as Arc<dyn apl_core::PluginInvoker>),
&(delegations.clone() as Arc<dyn apl_core::DelegationInvoker>),
)
.await;
assert!(
matches!(
bag.get("delegation.granted"),
Some(apl_core::attributes::AttributeValue::Bool(true))
),
"delegation.granted should be true; bag has: {:?}",
bag.get("delegation.granted"),
);
let perms = bag
.get_string_set("delegation.granted.permissions")
.expect("granted.permissions present");
assert!(perms.contains("read_compensation"));
assert_eq!(
decision.decision,
Decision::Allow,
"route should allow; got: {:?}",
decision.decision,
);
let calls = ledger.lock().unwrap().clone();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].plugin_name, "workday-oauth");
assert_eq!(calls[0].target_name, "workday-api");
assert_eq!(calls[0].required_permissions, vec!["read_compensation"]);
let final_ext = invoker.current_extensions().await;
let raw = final_ext
.raw_credentials
.as_ref()
.expect("raw_credentials present");
assert_eq!(raw.delegated_tokens.len(), 1, "one minted token");
let token = raw.delegated_tokens.values().next().unwrap();
assert_eq!(token.audience, "workday-api");
assert_eq!(token.scopes, vec!["read_compensation"]);
}
#[tokio::test]
async fn delegate_step_default_on_error_denies_route() {
let ledger: Arc<Mutex<Vec<DelegateCallRecord>>> = Arc::new(Mutex::new(Vec::new()));
let plugin = Arc::new(RecordingDelegate {
cfg: delegate_cfg("workday-oauth"),
ledger: Arc::clone(&ledger),
grant_scopes: None,
grant_audience: String::new(),
deny_code: Some("delegation.idp_rejected".to_string()),
observed_extensions: Arc::new(Mutex::new(None)),
});
let yaml = r#"
plugins:
- name: workday-oauth
kind: test
hooks: [token.delegate]
routes:
get_compensation:
policy:
- "delegate(workday-oauth, target: workday-api, permissions: [write_everything])"
"#;
let (mgr, cfg, cache) = build_setup(
yaml,
vec![(
"workday-oauth".to_string(),
Arc::clone(&plugin),
delegate_cfg("workday-oauth"),
)],
)
.await;
let route = cfg.routes.get("get_compensation").expect("route present");
let registry = cfg.plugins.clone();
let plan = cache.get_or_build(route, ®istry, &mgr).await;
let extensions = ext_with_bearer("eyJ.fake.user-jwt");
let session_store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let invoker = Arc::new(
CmfPluginInvoker::for_request(
Arc::clone(&mgr),
extensions,
cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(
cpex_core::cmf::enums::Role::User,
"fetch comp",
),
},
Arc::clone(&plan),
Arc::clone(&session_store),
)
.await
.expect("for_request"),
);
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&mgr),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let mut bag = apl_cmf::BagBuilder::new()
.with_extensions(&invoker.current_extensions().await)
.with_route_key(&route.route_key)
.build();
let mut payload = RoutePayload::new(serde_json::Value::Null);
let decision = evaluate_route(
route,
&mut bag,
&mut payload,
&(Arc::new(AllowPdp) as Arc<dyn apl_core::PdpResolver>),
&(invoker.clone() as Arc<dyn apl_core::PluginInvoker>),
&(delegations.clone() as Arc<dyn apl_core::DelegationInvoker>),
)
.await;
match decision.decision {
Decision::Deny { rule_source, .. } => {
assert_eq!(
rule_source, "delegation.idp_rejected",
"rule_source should carry the plugin's violation code",
);
},
d => panic!("expected Deny on plugin deny, got {d:?}"),
}
assert_eq!(
ledger.lock().unwrap().len(),
1,
"delegate plugin was called once",
);
}
#[tokio::test]
async fn delegate_step_on_error_continue_lets_pipeline_proceed() {
let ledger: Arc<Mutex<Vec<DelegateCallRecord>>> = Arc::new(Mutex::new(Vec::new()));
let plugin = Arc::new(RecordingDelegate {
cfg: delegate_cfg("audit-receipt"),
ledger: Arc::clone(&ledger),
grant_scopes: None,
grant_audience: String::new(),
deny_code: Some("audit.unavailable".to_string()),
observed_extensions: Arc::new(Mutex::new(None)),
});
let yaml = r#"
plugins:
- name: audit-receipt
kind: test
hooks: [token.delegate]
routes:
any:
policy:
- "delegate(audit-receipt, target: audit, on_error: continue)"
"#;
let (mgr, cfg, cache) = build_setup(
yaml,
vec![(
"audit-receipt".to_string(),
Arc::clone(&plugin),
delegate_cfg("audit-receipt"),
)],
)
.await;
let route = cfg.routes.get("any").expect("route present");
let registry = cfg.plugins.clone();
let plan = cache.get_or_build(route, ®istry, &mgr).await;
let extensions = ext_with_bearer("eyJ.fake.user-jwt");
let session_store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let invoker = Arc::new(
CmfPluginInvoker::for_request(
Arc::clone(&mgr),
extensions,
cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(cpex_core::cmf::enums::Role::User, "any"),
},
Arc::clone(&plan),
Arc::clone(&session_store),
)
.await
.expect("for_request"),
);
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&mgr),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let mut bag = apl_cmf::BagBuilder::new()
.with_extensions(&invoker.current_extensions().await)
.with_route_key(&route.route_key)
.build();
let mut payload = RoutePayload::new(serde_json::Value::Null);
let decision = evaluate_route(
route,
&mut bag,
&mut payload,
&(Arc::new(AllowPdp) as Arc<dyn apl_core::PdpResolver>),
&(invoker.clone() as Arc<dyn apl_core::PluginInvoker>),
&(delegations.clone() as Arc<dyn apl_core::DelegationInvoker>),
)
.await;
assert_eq!(
decision.decision,
Decision::Allow,
"on_error: continue → route allows despite plugin deny",
);
}
#[tokio::test]
async fn multiple_delegates_most_recent_wins_in_bag_extensions_accumulate() {
let ledger: Arc<Mutex<Vec<DelegateCallRecord>>> = Arc::new(Mutex::new(Vec::new()));
let workday = Arc::new(RecordingDelegate {
cfg: delegate_cfg("workday-oauth"),
ledger: Arc::clone(&ledger),
grant_scopes: Some(vec!["read_compensation".to_string()]),
grant_audience: "workday-api".to_string(),
deny_code: None,
observed_extensions: Arc::new(Mutex::new(None)),
});
let payroll = Arc::new(RecordingDelegate {
cfg: delegate_cfg("payroll-oauth"),
ledger: Arc::clone(&ledger),
grant_scopes: Some(vec!["read_salary".to_string()]),
grant_audience: "payroll-api".to_string(),
deny_code: None,
observed_extensions: Arc::new(Mutex::new(None)),
});
let yaml = r#"
plugins:
- name: workday-oauth
kind: test
hooks: [token.delegate]
- name: payroll-oauth
kind: test
hooks: [token.delegate]
routes:
fanout:
policy:
- "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
- "delegate(payroll-oauth, target: payroll-api, permissions: [read_salary])"
- "!(delegation.granted.permissions contains 'read_salary'): deny"
"#;
let (mgr, cfg, cache) = build_setup(
yaml,
vec![
(
"workday-oauth".to_string(),
Arc::clone(&workday),
delegate_cfg("workday-oauth"),
),
(
"payroll-oauth".to_string(),
Arc::clone(&payroll),
delegate_cfg("payroll-oauth"),
),
],
)
.await;
let route = cfg.routes.get("fanout").expect("route present");
let registry = cfg.plugins.clone();
let plan = cache.get_or_build(route, ®istry, &mgr).await;
let extensions = ext_with_bearer("eyJ.fake.user-jwt");
let session_store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let invoker = Arc::new(
CmfPluginInvoker::for_request(
Arc::clone(&mgr),
extensions,
cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(cpex_core::cmf::enums::Role::User, "fanout"),
},
Arc::clone(&plan),
Arc::clone(&session_store),
)
.await
.expect("for_request"),
);
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&mgr),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let mut bag = apl_cmf::BagBuilder::new()
.with_extensions(&invoker.current_extensions().await)
.with_route_key(&route.route_key)
.build();
let mut payload = RoutePayload::new(serde_json::Value::Null);
let decision = evaluate_route(
route,
&mut bag,
&mut payload,
&(Arc::new(AllowPdp) as Arc<dyn apl_core::PdpResolver>),
&(invoker.clone() as Arc<dyn apl_core::PluginInvoker>),
&(delegations.clone() as Arc<dyn apl_core::DelegationInvoker>),
)
.await;
assert_eq!(decision.decision, Decision::Allow);
let calls = ledger.lock().unwrap().clone();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].plugin_name, "workday-oauth");
assert_eq!(calls[1].plugin_name, "payroll-oauth");
let final_ext = invoker.current_extensions().await;
let raw = final_ext.raw_credentials.as_ref().unwrap();
assert_eq!(raw.delegated_tokens.len(), 2);
let auds: std::collections::HashSet<&str> = raw
.delegated_tokens
.values()
.map(|t| t.audience.as_str())
.collect();
assert!(auds.contains("workday-api"));
assert!(auds.contains("payroll-api"));
}
#[tokio::test]
async fn delegate_with_read_subject_sees_subject_but_not_labels() {
let ledger: Arc<Mutex<Vec<DelegateCallRecord>>> = Arc::new(Mutex::new(Vec::new()));
let observed: Arc<Mutex<Option<ExtensionsObservation>>> = Arc::new(Mutex::new(None));
let plugin_cfg = delegate_cfg_with_caps(
"scoped-delegate",
&["read_subject", "read_inbound_credentials"],
);
let plugin = Arc::new(RecordingDelegate {
cfg: plugin_cfg.clone(),
ledger: Arc::clone(&ledger),
grant_scopes: Some(vec!["read_compensation".to_string()]),
grant_audience: "workday-api".to_string(),
deny_code: None,
observed_extensions: Arc::clone(&observed),
});
let yaml = r#"
plugins:
- name: scoped-delegate
kind: test
hooks: [token.delegate]
routes:
get_compensation:
policy:
- "delegate(scoped-delegate, target: workday-api, permissions: [read_compensation])"
"#;
let (mgr, cfg, cache) = build_setup(
yaml,
vec![(
"scoped-delegate".to_string(),
Arc::clone(&plugin),
plugin_cfg,
)],
)
.await;
let route = cfg.routes.get("get_compensation").expect("route present");
let registry = cfg.plugins.clone();
let plan = cache.get_or_build(route, ®istry, &mgr).await;
let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii");
let session_store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let invoker = Arc::new(
CmfPluginInvoker::for_request(
Arc::clone(&mgr),
extensions,
cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(
cpex_core::cmf::enums::Role::User,
"fetch compensation",
),
},
Arc::clone(&plan),
Arc::clone(&session_store),
)
.await
.expect("for_request"),
);
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&mgr),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let mut bag = apl_cmf::BagBuilder::new()
.with_extensions(&invoker.current_extensions().await)
.with_route_key(&route.route_key)
.build();
let mut payload = RoutePayload::new(serde_json::Value::Null);
let _ = evaluate_route(
route,
&mut bag,
&mut payload,
&(Arc::new(AllowPdp) as Arc<dyn apl_core::PdpResolver>),
&(invoker.clone() as Arc<dyn apl_core::PluginInvoker>),
&(delegations.clone() as Arc<dyn apl_core::DelegationInvoker>),
)
.await;
let obs = observed
.lock()
.unwrap()
.clone()
.expect("plugin should have run and recorded its view");
assert_eq!(
obs.saw_subject_id.as_deref(),
Some("alice"),
"read_subject cap should expose subject.id",
);
assert!(
obs.saw_inbound_token_for_user,
"read_inbound_credentials cap should expose the inbound user token",
);
assert!(
obs.saw_labels.is_empty(),
"without read_labels, the label should NOT leak — saw: {:?}",
obs.saw_labels,
);
}
#[tokio::test]
async fn delegate_without_caps_sees_stripped_extensions() {
let ledger: Arc<Mutex<Vec<DelegateCallRecord>>> = Arc::new(Mutex::new(Vec::new()));
let observed: Arc<Mutex<Option<ExtensionsObservation>>> = Arc::new(Mutex::new(None));
let plugin_cfg = delegate_cfg_with_caps("capless-delegate", &[]);
let plugin = Arc::new(RecordingDelegate {
cfg: plugin_cfg.clone(),
ledger: Arc::clone(&ledger),
grant_scopes: Some(vec!["read_compensation".to_string()]),
grant_audience: "workday-api".to_string(),
deny_code: None,
observed_extensions: Arc::clone(&observed),
});
let yaml = r#"
plugins:
- name: capless-delegate
kind: test
hooks: [token.delegate]
routes:
any:
policy:
- "delegate(capless-delegate, target: workday-api)"
"#;
let (mgr, cfg, cache) = build_setup(
yaml,
vec![(
"capless-delegate".to_string(),
Arc::clone(&plugin),
plugin_cfg,
)],
)
.await;
let route = cfg.routes.get("any").expect("route present");
let registry = cfg.plugins.clone();
let plan = cache.get_or_build(route, ®istry, &mgr).await;
let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii");
let session_store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let invoker = Arc::new(
CmfPluginInvoker::for_request(
Arc::clone(&mgr),
extensions,
cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(cpex_core::cmf::enums::Role::User, "any"),
},
Arc::clone(&plan),
Arc::clone(&session_store),
)
.await
.expect("for_request"),
);
let delegations = Arc::new(DelegationPluginInvoker::new(
Arc::clone(&mgr),
invoker.extensions_arc(),
invoker.plan_arc(),
));
let mut bag = apl_cmf::BagBuilder::new()
.with_extensions(&invoker.current_extensions().await)
.with_route_key(&route.route_key)
.build();
let mut payload = RoutePayload::new(serde_json::Value::Null);
let _ = evaluate_route(
route,
&mut bag,
&mut payload,
&(Arc::new(AllowPdp) as Arc<dyn apl_core::PdpResolver>),
&(invoker.clone() as Arc<dyn apl_core::PluginInvoker>),
&(delegations.clone() as Arc<dyn apl_core::DelegationInvoker>),
)
.await;
let obs = observed
.lock()
.unwrap()
.clone()
.expect("plugin should have run");
assert!(
obs.saw_subject_id.is_none(),
"without read_subject, subject must be hidden — saw: {:?}",
obs.saw_subject_id,
);
assert!(
obs.saw_labels.is_empty(),
"without read_labels, labels must be hidden",
);
assert!(
!obs.saw_inbound_token_for_user,
"without read_inbound_credentials, inbound token must be hidden",
);
}