diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ea97bd..3fc67362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- **BREAKING — APL authz/authn config keys renamed** for clarity. The old key names no longer parse; a config using them fails to load with an error naming the replacement (a dropped authorization or authentication block would otherwise fail open, so the rejection is deliberate). Migration: + - `identity:` → `authentication:` (at `global`, per-route, and policy-group scope) + - `policy:` → `authorization.pre_invocation:` (or flat `pre_invocation:`) + - `post_policy:` → `authorization.post_invocation:` (or flat `post_invocation:`) + + The two authorization phases may be written either nested under an `authorization:` block or flat directly on the section; the forms are equivalent. The field-pipeline keys `args:` / `result:` are unchanged (they stay aligned with the `args.*` / `result.*` attribute namespaces that predicates and interpolation read). Internal APL IR is unchanged. (#105) + ## [0.2.0] - 2026-06-26 ### Added - CPEX redesign as a Rust framework with Go bindings -- APL (Attribute Policy Language) governance is now bundled into `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers the standard APL plugin/PDP factories (`validator/pii-scan`, `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and installs the APL config visitor on a manager. Call it after `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use `PluginManager.EnableAPL()`. (#60) +- APL (Authorization Policy Language) governance is now bundled into `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers the standard APL plugin/PDP factories (`validator/pii-scan`, `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and installs the APL config visitor on a manager. Call it after `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use `PluginManager.EnableAPL()`. (#60) - Publish `libcpex_ffi.a` as signed GitHub Release artifacts on every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign keyless signatures + SHA256 checksums; see `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and-consume recipe. (#60) - FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` and panics on mismatch. Other language bindings must replicate the check. (#60) - CEL (Common Expression Language) policy decision backend. A new `apl-pdp-cel` crate registers `kind: cel`, letting authors write inline boolean predicates (`cel: { expr: ... }`) over the common attribute vocabulary (`subject.id`, `delegation.depth`, `session.labels`, ...), evaluated through the existing `PdpResolver` seam alongside Cedar, OPA, and AuthZen. Expressions compile once and cache by source; compile errors, undeclared-variable references, and non-boolean results fail closed (deny), overridable with `on_error: allow`. No change to APL evaluation semantics. (#68) diff --git a/README.md b/README.md index de8ac16b..1efeb7b0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ One policy defines three distinct enforcement pipelines, one for each entity. routes: # HR lookup: gate on role, scope a downstream token, redact by permission, taint the session. - tool: get_compensation - policy: + pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - "taint(secret, session)" @@ -59,7 +59,7 @@ routes: # Repo search: gate on team, decide with CEL (or Cedar), require the scoped grant. - tool: search_repos - policy: + pre_invocation: - "require(team.engineering | team.security)" - cel: expr: "(role.engineer && args.visibility == 'internal') || role.security" @@ -69,7 +69,7 @@ routes: # Outbound email: refuse if the session already touched secret data. - tool: send_email - policy: + pre_invocation: - "require(perm.email_send)" - "run(pii-scan)" - "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')" diff --git a/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs b/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs index 953747f7..09828aa5 100644 --- a/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs +++ b/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs @@ -49,7 +49,7 @@ global: routes: - tool: get_document apl: - policy: + pre_invocation: - cedar: action: 'Action::"read"' resource: diff --git a/builtins/pdps/cel/tests/visitor_cel_config.rs b/builtins/pdps/cel/tests/visitor_cel_config.rs index ae0cc964..696a5605 100644 --- a/builtins/pdps/cel/tests/visitor_cel_config.rs +++ b/builtins/pdps/cel/tests/visitor_cel_config.rs @@ -45,7 +45,7 @@ global: routes: - tool: get_document apl: - policy: + pre_invocation: - cel: expr: | subject.id == "alice" && has(role.reader) && role.reader @@ -177,7 +177,7 @@ global: routes: - tool: get_document apl: - policy: + pre_invocation: - cel: expr: | subject.id == "alice" @@ -208,7 +208,7 @@ global: routes: - tool: get_document apl: - policy: + pre_invocation: - cel: expr: | nonexistent.field == "value" @@ -250,7 +250,7 @@ global: routes: - tool: get_document apl: - policy: + pre_invocation: - cel: on_deny: - deny @@ -294,7 +294,7 @@ global: routes: - tool: get_document apl: - policy: + pre_invocation: - cel: expr: | meta.entity_name == "get_document" diff --git a/crates/apl-cmf/tests/end_to_end.rs b/crates/apl-cmf/tests/end_to_end.rs index 57392f7b..baa73b3d 100644 --- a/crates/apl-cmf/tests/end_to_end.rs +++ b/crates/apl-cmf/tests/end_to_end.rs @@ -42,7 +42,7 @@ routes: get_employee: args: employee_id: "str" - policy: + pre_invocation: - "require(authenticated)" - "delegation.depth > 2: deny" result: @@ -260,7 +260,7 @@ async fn args_attributes_flow_into_bag_for_policy_use() { let yaml = r#" routes: guarded_route: - policy: + pre_invocation: - "args.include_ssn == true: deny" "#; let routes = compile_config(yaml).unwrap().routes; @@ -285,7 +285,11 @@ routes: .await; match r.decision { Decision::Deny { rule_source, .. } => { - assert!(rule_source.contains("policy"), "got source {}", rule_source); + assert!( + rule_source.contains("pre_invocation"), + "got source {}", + rule_source + ); }, d => panic!("expected Deny on include_ssn, got {:?}", d), } diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml index 340566c5..22ae1358 100644 --- a/crates/apl-core/Cargo.toml +++ b/crates/apl-core/Cargo.toml @@ -8,7 +8,7 @@ [package] name = "apl-core" -description = "APL — Attribute Policy Language core (compiler + evaluator)" +description = "APL — Authorization Policy Language core (compiler + evaluator)" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs index 48d12e7c..74078d1a 100644 --- a/crates/apl-core/src/lib.rs +++ b/crates/apl-core/src/lib.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// APL core — Attribute Policy Language compiler + evaluator. +// APL core — Authorization Policy Language compiler + evaluator. // // This crate is the language nucleus. It does not depend on CPEX directly; // the bridge from cpex-core extensions into the AttributeBag lives in @@ -11,7 +11,7 @@ // // See docs/specs/apl-design.md for the full design. -#![doc = "APL — Attribute Policy Language. See docs/specs/apl-design.md."] +#![doc = "APL — Authorization Policy Language. See docs/specs/apl-design.md."] pub mod attributes; pub mod evaluator; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index fc7a0f19..ff49bc87 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -15,7 +15,8 @@ // ✓ Predicate grammar: identifiers, literals, comparisons, contains, // & | ! parens, require(...) // ✓ Actions: deny / allow / (default deny on missing) -// ✓ YAML top-level routes: keyed map, policy: / post_policy: blocks +// ✓ YAML top-level routes: keyed map, authorization.pre_invocation / +// post_invocation blocks (flat pre_invocation:/post_invocation: too) // ✗ Steps (cedar:(), opa(), plugin(), taint()) — rejected with clear errors // ✗ Pipe chains in args:/result: — fields parsed, values stashed as opaque // ✗ `in` / `not in` / `exists()` — need IR variants first; rejected @@ -48,6 +49,13 @@ pub enum ParseError { #[error("predicate '{predicate}': {msg}")] Predicate { predicate: String, msg: String }, + + #[error("in `{location}`: config field `{old}` was renamed to `{new}` — update your config")] + RenamedField { + location: String, + old: String, + new: String, + }, } // ===================================================================== @@ -819,10 +827,10 @@ fn strip_string_literal(s: &str, rule: &str) -> Result { } // ===================================================================== -// Step parser (policy: / post_policy: entries — supports steps + rules) +// Step parser (pre_invocation / post_invocation entries — steps + rules) // ===================================================================== -/// Parse a single YAML entry from a `policy:` / `post_policy:` list. +/// Parse a single YAML entry from a `pre_invocation` / `post_invocation` list. /// /// Two YAML shapes (DSL §3.2 + §7): /// - **String entry** — a rule line, taint effect, or plugin call. @@ -2200,13 +2208,22 @@ pub struct ConfigYaml { #[derive(Debug, Default, Deserialize)] pub struct RouteYaml { - /// Each entry is either a string (rule / plugin / taint) or a - /// single-key map (PDP call with reactions). See `parse_step`. + /// Flat pre-invocation authorization effects (was `policy:`). Each + /// entry is either a string (rule / plugin / taint) or a single-key + /// map (PDP call with reactions). See `parse_step`. Merged with any + /// `authorization.pre_invocation` entries. + #[serde(default)] + pub pre_invocation: Vec, + + /// Flat post-invocation authorization effects (was `post_policy:`). + /// Merged with any `authorization.post_invocation` entries. #[serde(default)] - pub policy: Vec, + pub post_invocation: Vec, + /// Nested `authorization:` block — `{ pre_invocation, post_invocation }`. + /// Equivalent to the flat forms; entries from both are concatenated. #[serde(default)] - pub post_policy: Vec, + pub authorization: Option, /// `args:` field → pipe-chain string. Compiled to per-field pipelines. #[serde(default)] @@ -2222,11 +2239,25 @@ pub struct RouteYaml { #[serde(default)] pub plugins: HashMap, - /// Anything else on the route (meta, taint, when) — stashed. + /// Anything else on the route (meta, taint, when) — stashed. Also + /// where renamed legacy keys land; `reject_legacy_keys` fails loudly + /// on them so a dropped authz block never fails open. #[serde(flatten)] pub other: HashMap, } +/// Nested `authorization:` block. Both sub-lists are optional and default +/// to empty; each is compiled the same way as the flat `pre_invocation:` / +/// `post_invocation:` forms. +#[derive(Debug, Default, Deserialize)] +pub struct AuthorizationYaml { + #[serde(default)] + pub pre_invocation: Vec, + + #[serde(default)] + pub post_invocation: Vec, +} + /// Output of [`compile_config`] — the routes that have APL blocks plus /// the registry of plugin declarations from the root `plugins:` block. /// @@ -2242,8 +2273,9 @@ pub struct CompiledConfig { /// Compile a YAML config into a [`CompiledConfig`] (routes + plugin /// registry). /// -/// Routes with no APL fields populated (no `policy:` / `post_policy:` / -/// `args:` / `result:`) are **omitted from `routes`**, per apl-design §5 +/// Routes with no APL fields populated (no `authorization:` / +/// `pre_invocation:` / `post_invocation:` / `args:` / `result:`) are +/// **omitted from `routes`**, per apl-design §5 /// "Routes without APL blocks fall back to legacy plugin-chain execution." /// A route-level `plugins:` override block alone is not enough — overrides /// only have meaning when the route actually dispatches plugins via APL @@ -2265,9 +2297,51 @@ pub fn compile_config(yaml: &str) -> Result { Ok(CompiledConfig { routes, plugins }) } +/// Legacy field names, mapped to their replacements. Because unknown keys +/// land in `RouteYaml.other` via `#[serde(flatten)]`, a config still using +/// an old name would otherwise be *silently dropped* — dropping a `policy:` +/// block fails open (no authorization enforced). We reject them loudly +/// instead. `identity` is renamed in cpex-core config, not here. +const RENAMED_FIELDS: [(&str, &str); 2] = [ + ( + "policy", + "authorization.pre_invocation (or flat pre_invocation)", + ), + ( + "post_policy", + "authorization.post_invocation (or flat post_invocation)", + ), +]; + +/// Fail loudly if a stashed key is a renamed legacy field, so a dropped +/// authz block never fails open. Run before the has-APL gate. +fn reject_legacy_keys( + location: &str, + other: &HashMap, +) -> Result<(), ParseError> { + for (old, new) in RENAMED_FIELDS { + if other.contains_key(old) { + return Err(ParseError::RenamedField { + location: location.to_string(), + old: old.to_string(), + new: new.to_string(), + }); + } + } + Ok(()) +} + fn compile_route(route_key: &str, raw: RouteYaml) -> Result, ParseError> { - let has_apl = !raw.policy.is_empty() - || !raw.post_policy.is_empty() + // Reject legacy keys *before* the gate: a legacy-only route would + // otherwise look empty and be silently omitted (fail open). + reject_legacy_keys(route_key, &raw.other)?; + let has_authz = raw + .authorization + .as_ref() + .is_some_and(|a| !a.pre_invocation.is_empty() || !a.post_invocation.is_empty()); + let has_apl = !raw.pre_invocation.is_empty() + || !raw.post_invocation.is_empty() + || has_authz || !raw.args.is_empty() || !raw.result.is_empty(); if !has_apl { @@ -2276,19 +2350,32 @@ fn compile_route(route_key: &str, raw: RouteYaml) -> Result Result { + reject_legacy_keys(source, &raw.other)?; let mut route = CompiledRoute::new(source); - for (i, entry) in raw.policy.iter().enumerate() { - let step = parse_step(entry, &format!("{}.policy[{}]", source, i))?; + let (auth_pre, auth_post) = raw + .authorization + .map(|a| (a.pre_invocation, a.post_invocation)) + .unwrap_or_default(); + for (i, entry) in auth_pre.iter().chain(raw.pre_invocation.iter()).enumerate() { + let step = parse_step(entry, &format!("{}.pre_invocation[{}]", source, i))?; route.policy.push(step_to_top_level_effect(step)?); } - for (i, entry) in raw.post_policy.iter().enumerate() { - let step = parse_step(entry, &format!("{}.post_policy[{}]", source, i))?; + for (i, entry) in auth_post + .iter() + .chain(raw.post_invocation.iter()) + .enumerate() + { + let step = parse_step(entry, &format!("{}.post_invocation[{}]", source, i))?; route.post_policy.push(step_to_top_level_effect(step)?); } for (field, chain) in &raw.args { @@ -2323,12 +2410,13 @@ fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result 2 & include_ssn: deny" @@ -3309,10 +3397,111 @@ routes: .contains(crate::rules::Phase::Policy)); } + #[test] + fn authorization_nested_and_flat_forms_are_equivalent() { + // The nested `authorization:` block and the flat + // `pre_invocation:` / `post_invocation:` forms must compile to + // the same route. + let nested = r#" +routes: + r: + authorization: + pre_invocation: + - "require(authenticated)" + post_invocation: + - "taint(audit, session)" +"#; + let flat = r#" +routes: + r: + pre_invocation: + - "require(authenticated)" + post_invocation: + - "taint(audit, session)" +"#; + let a = compile_config(nested).unwrap().routes; + let b = compile_config(flat).unwrap().routes; + let ra = a.get("r").expect("nested route"); + let rb = b.get("r").expect("flat route"); + assert_eq!(ra.policy.len(), rb.policy.len()); + assert_eq!(ra.post_policy.len(), rb.post_policy.len()); + assert_eq!(ra.policy.len(), 1); + assert_eq!(ra.post_policy.len(), 1); + } + + #[test] + fn authorization_nested_and_flat_entries_concatenate() { + // When both the nested block and the flat lists are present, + // entries are concatenated (nested first, then flat). + let yaml = r#" +routes: + r: + authorization: + pre_invocation: + - "require(authenticated)" + pre_invocation: + - "require(role.hr)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("r").expect("route"); + assert_eq!(route.policy.len(), 2); + } + + #[test] + fn field_pipeline_error_names_field_path() { + // A malformed pipeline under `result:` names `result.` in + // the diagnostic so the operator can locate the offending field. + let yaml = r#" +routes: + r: + result: + x: "nonsense" +"#; + let err = compile_config(yaml).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("result.x"), "expected result.x in: {msg}"); + } + + #[test] + fn legacy_policy_field_names_are_rejected() { + // Breaking rename: the old authorization-phase keys must fail + // loudly, never be silently dropped (which would fail open). + for (old, hint) in [ + ("policy", "pre_invocation"), + ("post_policy", "post_invocation"), + ] { + let yaml = format!("routes:\n r:\n {old}:\n - \"require(authenticated)\"\n"); + let err = compile_config(&yaml).expect_err(&format!("legacy `{old}` must be rejected")); + let msg = format!("{err}"); + assert!( + msg.contains(old) && msg.contains(hint), + "`{old}` rejection should name the replacement `{hint}`: {msg}" + ); + } + } + + #[test] + fn legacy_only_route_is_not_silently_omitted() { + // A route whose *only* APL-ish key is a legacy name would look + // empty to the has-APL gate and be dropped — a fail-open. It must + // error instead. + let yaml = r#" +routes: + ghost: + policy: + - "require(authenticated)" +"#; + assert!( + matches!(compile_config(yaml), Err(ParseError::RenamedField { .. })), + "legacy-only route must be rejected, not omitted" + ); + } + #[test] fn compile_omits_routes_without_apl_blocks() { - // A route with no APL blocks (no policy / post_policy / args / - // result) is a "legacy" route per apl-design §5 and must be + // A route with no APL blocks (no authorization / pre_invocation / + // post_invocation / args / result) is a "legacy" route per + // apl-design §5 and must be // omitted from the compiled output. Unknown route keys (e.g. // legacy CPEX `priority`) are stashed in `other`, not errored. let yaml = r#" @@ -3320,7 +3509,7 @@ routes: legacy: priority: 50 apl_route: - policy: + pre_invocation: - "require(authenticated)" "#; let routes = compile_config(yaml).unwrap().routes; @@ -3344,7 +3533,7 @@ imports: - "./shared.yaml" routes: ping: - policy: + pre_invocation: - "require(authenticated)" "#; let routes = compile_config(yaml).unwrap().routes; @@ -3356,7 +3545,7 @@ routes: let yaml = r#" routes: bad: - policy: + pre_invocation: - "subject.id == garbage_ident" "#; let err = compile_config(yaml).unwrap_err(); @@ -3374,7 +3563,7 @@ routes: let yaml = r#" routes: rate_limited: - policy: + pre_invocation: - "plugin(rate_limiter)" "#; let routes = compile_config(yaml).unwrap().routes; @@ -3393,7 +3582,7 @@ routes: let yaml = r#" routes: rate_limited: - policy: + pre_invocation: - "run(rate_limiter)" "#; let routes = compile_config(yaml).unwrap().routes; @@ -3427,7 +3616,7 @@ routes: let yaml = r#" routes: audit_marked: - policy: + pre_invocation: - "taint(audit, session)" "#; let routes = compile_config(yaml).unwrap().routes; @@ -3447,7 +3636,7 @@ routes: let yaml = r#" routes: authz_check: - policy: + pre_invocation: - cedar: action: read resource: employee @@ -3485,7 +3674,7 @@ routes: let yaml = r#" routes: authz_check: - policy: + pre_invocation: - cel: expr: "subject.id == 'alice' && delegation.depth <= 2" on_deny: @@ -3517,7 +3706,7 @@ routes: let yaml = r#" routes: opa_check: - policy: + pre_invocation: - 'opa("hr/compensation/deny"):': on_deny: - deny @@ -3540,7 +3729,7 @@ routes: let yaml = r#" routes: custom_pdp: - policy: + pre_invocation: - my_engine: on_deny: [deny] "#; @@ -3560,7 +3749,7 @@ routes: let yaml = r#" routes: get_compensation: - policy: + pre_invocation: - "require(authenticated)" - "require(role.hr | role.finance)" - "delegation.depth > 2: deny" @@ -3610,8 +3799,8 @@ routes: { Decision::Deny { rule_source, .. } => { assert!( - rule_source.contains("policy[2]"), - "expected policy[2], got {}", + rule_source.contains("pre_invocation[2]"), + "expected pre_invocation[2], got {}", rule_source ); }, @@ -3636,8 +3825,8 @@ routes: { Decision::Deny { rule_source, .. } => { assert!( - rule_source.contains("policy[1]"), - "expected policy[1], got {}", + rule_source.contains("pre_invocation[1]"), + "expected pre_invocation[1], got {}", rule_source ); }, @@ -3894,8 +4083,8 @@ routes: #[test] fn compile_route_with_only_args_still_compiles() { - // A route with no `policy:` but with `args:` validators is still - // an APL route (declared_phases is non-empty). + // A route with no authorization block but with `args:` + // validators is still an APL route (declared_phases non-empty). let yaml = r#" routes: validate_only: @@ -3935,7 +4124,7 @@ plugins: hooks: [tool_post_invoke] routes: get_compensation: - policy: + pre_invocation: - "plugin(rate_limiter)" "#; let cfg = compile_config(yaml).unwrap(); @@ -3959,7 +4148,7 @@ plugins: max_requests: 100 routes: hot_path: - policy: + pre_invocation: - "plugin(rate_limiter)" plugins: rate_limiter: @@ -3994,7 +4183,7 @@ routes: #[test] fn compile_policy_block_value_parses_apl_body() { let yaml = r#" -policy: +pre_invocation: - "require(authenticated)" result: ssn: "redact(!perm.view_ssn)" @@ -4020,14 +4209,14 @@ result: #[test] fn compile_policy_block_value_threads_source_into_rule_paths() { let yaml = r#" -policy: +pre_invocation: - "require(authenticated)" "#; let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile"); match &compiled.policy[0] { crate::rules::Effect::When { source, .. } => { - assert_eq!(source, "global.policies.hr.policy[0]"); + assert_eq!(source, "global.policies.hr.pre_invocation[0]"); }, other => panic!("expected When, got {:?}", other), } @@ -4294,7 +4483,7 @@ policy: let yaml = r#" routes: get_compensation: - policy: + pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - delegate: @@ -4330,7 +4519,7 @@ routes: let yaml = r#" routes: get_compensation: - policy: + pre_invocation: - "require(role.hr)" - delegate: plugin: workday-oauth @@ -4338,7 +4527,7 @@ routes: target: workday-api permissions: [read_compensation] - "require(authenticated)" - post_policy: + post_invocation: - delegate: plugin: audit-biscuit on_error: continue @@ -4352,7 +4541,7 @@ routes: panic!("expected Delegate at policy[1], got {:?}", route.policy[1]); }; assert_eq!(ds.plugin_name, "workday-oauth"); - assert_eq!(ds.source, "get_compensation.policy[1]"); + assert_eq!(ds.source, "get_compensation.pre_invocation[1]"); // post_policy[0] is the audit-biscuit delegate. let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else { @@ -4360,7 +4549,7 @@ routes: }; assert_eq!(post_ds.plugin_name, "audit-biscuit"); assert_eq!(post_ds.on_error.as_deref(), Some("continue")); - assert_eq!(post_ds.source, "get_compensation.post_policy[0]"); + assert_eq!(post_ds.source, "get_compensation.post_invocation[0]"); } // ----- validate(name) compile-time rejection (DSL spec §4.2) ----- diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs index 52841d7c..d8f0dc48 100644 --- a/crates/apl-core/src/route.rs +++ b/crates/apl-core/src/route.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// Phase orchestration: runs `args → policy → result → post_policy` against a +// Phase orchestration: runs `inputs → pre_invocation → outputs → post_invocation` against a // `CompiledRoute` and a mutable payload, returning a unified decision plus // accumulated taints. // diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index 9a434dd7..d0e74acb 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -75,7 +75,7 @@ pub(crate) enum Step { }, } -/// One delegation invocation inside `policy:` or `post_policy:`. +/// One delegation invocation inside `pre_invocation:` or `post_invocation:`. /// /// At runtime the apl-cpex `DelegationInvoker` constructs a /// `cpex_core::delegation::DelegationPayload` from @@ -107,7 +107,7 @@ pub(crate) enum Step { /// rules. /// /// For fan-out flows that need multiple independently-queryable -/// grants, split into `policy:` + `post_policy:` or reach for a +/// grants, split into `pre_invocation:` + `post_invocation:` or reach for a /// future per-step `as:` alias (not in v0; see the design doc's /// "Open design questions" section). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -245,10 +245,10 @@ pub trait PdpFactory: Send + Sync { /// post phases (e.g. `cmf.tool_pre_invoke` AND `cmf.tool_post_invoke`). /// /// APL's four phases map to two dispatch phases: -/// * `args:` field stages → `Pre` -/// * `policy:` steps → `Pre` -/// * `result:` field stages → `Post` -/// * `post_policy:` steps → `Post` +/// * `args:` field stages → `Pre` +/// * `pre_invocation:` steps → `Pre` +/// * `result:` field stages → `Post` +/// * `post_invocation:` steps → `Post` /// /// Plugins that need to discriminate `args` vs `policy` (same `Pre` /// from the dispatcher's perspective) inspect `PluginContext::hook_name()` @@ -275,7 +275,7 @@ pub enum DispatchPhase { /// plugin registered for multiple hooks. #[derive(Debug, Clone, Copy)] pub enum PluginInvocation<'a> { - /// Called from a `policy:` or `post_policy:` step. The plugin operates + /// Called from a `pre_invocation:` or `post_invocation:` step. The plugin operates /// on whatever typed payload the invoker was bound to. Step { phase: DispatchPhase }, /// Called inside an `args:` / `result:` pipe chain on one field. diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs index d38c25de..62010191 100644 --- a/crates/apl-core/tests/yaml_end_to_end.rs +++ b/crates/apl-core/tests/yaml_end_to_end.rs @@ -42,7 +42,7 @@ routes: get_employee: args: employee_id: "str" - policy: + pre_invocation: - "require(authenticated)" - "delegation.depth > 2: deny" result: @@ -196,7 +196,7 @@ async fn deep_delegation_denies_at_policy() { match r.decision { Decision::Deny { rule_source, .. } => { assert!( - rule_source.contains("policy"), + rule_source.contains("pre_invocation"), "got source: {}", rule_source ); diff --git a/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs index e280bd21..71ca4521 100644 --- a/crates/apl-cpex/src/delegation_invoker.rs +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -12,7 +12,7 @@ // // The apl-core evaluator calls // `DelegationInvoker::delegate(&DelegateStep)` once per `Step::Delegate` -// it encounters in a `policy:` / `post_policy:` block. The invoker: +// it encounters in a `pre_invocation:` / `post_invocation:` block. The invoker: // // 1. Looks up the resolved `token.delegate` entry for the step's // plugin name in the dispatch plan. @@ -95,8 +95,8 @@ impl DelegationPluginInvoker { impl DelegationInvoker for DelegationPluginInvoker { async fn delegate(&self, step: &DelegateStep) -> Result { // 1. Resolve the plugin's token.delegate entry from the plan. - // Routes that don't reference this plugin in `policy:` / - // `post_policy:` at compile time won't have it in the plan + // Routes that don't reference this plugin in `pre_invocation:` / + // `post_invocation:` at compile time won't have it in the plan // — surface that as NotFound so the evaluator's on_error // semantics kick in. let entry = self diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs index 1b14538a..1344518e 100644 --- a/crates/apl-cpex/src/dispatch_plan.rs +++ b/crates/apl-cpex/src/dispatch_plan.rs @@ -82,8 +82,8 @@ impl RoutePluginEntry { /// `MetaExtension.entity_type` (or `None` if the dispatcher /// doesn't have one — in which case any hook's entity_type /// matches). `requested_phase` comes from the APL invocation - /// context — `Pre` for `args:` / `policy:`, `Post` for - /// `result:` / `post_policy:`, `Unphased` for unphased + /// context — `Pre` for `args:` / `pre_invocation:`, `Post` for + /// `result:` / `post_invocation:`, `Unphased` for unphased /// dispatchers (rare in APL). /// /// Returns `None` when the plugin has no hook matching the diff --git a/crates/apl-cpex/src/parallel_safety.rs b/crates/apl-cpex/src/parallel_safety.rs index 43357a60..d5018730 100644 --- a/crates/apl-cpex/src/parallel_safety.rs +++ b/crates/apl-cpex/src/parallel_safety.rs @@ -79,8 +79,8 @@ pub fn validate_parallel_plugin_modes( ) -> Result<(), String> { let mut errors: Vec = Vec::new(); for (phase_name, effects) in [ - ("policy", route.policy.as_slice()), - ("post_policy", route.post_policy.as_slice()), + ("pre_invocation", route.policy.as_slice()), + ("post_invocation", route.post_policy.as_slice()), ] { for (idx, effect) in effects.iter().enumerate() { walk_effect( @@ -340,6 +340,6 @@ mod tests { let mut route = CompiledRoute::new("test_route"); route.post_policy = vec![rule(vec![parallel_plugin("mutator")])]; let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); - assert!(err.contains("post_policy")); + assert!(err.contains("post_invocation")); } } diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index 16cf4cf6..1048b1cd 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -54,7 +54,7 @@ use crate::pdp_router::PdpRouter; use crate::session_store::SessionStore; /// Which APL phase this handler runs. Pre covers `args` + `policy`; Post -/// covers `result` + `post_policy`. Set once at construction and never +/// covers `result` + `post_invocation`. Set once at construction and never /// changes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { @@ -394,7 +394,7 @@ impl AnyHookHandler for AplRouteHandler { Some(Box::new(updated) as Box) } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content() { - // A `policy:` plugin mutated the message directly via + // A `pre_invocation:` plugin mutated the message directly via // `modify_payload` (not through a field pipeline). Pass // the invoker's view through unchanged. Some(Box::new(final_payload) as Box) diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs index ccb07b17..a1ebf26b 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -360,11 +360,12 @@ impl ConfigVisitor for AplConfigVisitor { _mgr: &Arc, yaml: &serde_yaml::Value, ) -> Result<(), VisitorError> { + reject_legacy_apl_keys("global", yaml)?; let Some(apl_block) = apl_subblock(yaml) else { return Ok(()); }; - // Process `apl.pdp[]` before stacking the policy/post_policy + // Process `apl.pdp[]` before stacking the pre/post-invocation // layer — route handlers that reference PDPs need them // resolvable by the time `visit_route` runs. if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) { @@ -382,9 +383,10 @@ impl ConfigVisitor for AplConfigVisitor { // The `pdp:` / `session_store:` sub-keys aren't APL DSL fields; // strip them before handing the block to // `compile_policy_block_value` so the compiler doesn't see unknown - // keys. `compile_policy_block_value` accepts maps with `policy:` / - // `post_policy:` / `args:` / `result:` / `plugins:` (and inert - // fields it ignores), so a shallow strip on a clone is enough. + // keys. `compile_policy_block_value` accepts maps with + // `authorization:` / `pre_invocation:` / `post_invocation:` / + // `args:` / `result:` / `plugins:` (and inert fields it ignores), + // so a shallow strip on a clone is enough. let policy_only = strip_non_dsl_keys(&apl_block); let compiled = compile_policy_block_value("global.apl", &policy_only) .map_err(|e| Box::new(e) as VisitorError)?; @@ -401,10 +403,11 @@ impl ConfigVisitor for AplConfigVisitor { entity_type: &str, yaml: &serde_yaml::Value, ) -> Result<(), VisitorError> { + let source = format!("global.defaults.{}.apl", entity_type); + reject_legacy_apl_keys(&source, yaml)?; let Some(apl_block) = apl_subblock(yaml) else { return Ok(()); }; - let source = format!("global.defaults.{}.apl", entity_type); warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block); let compiled = compile_policy_block_value(&source, &apl_block) .map_err(|e| Box::new(e) as VisitorError)?; @@ -422,10 +425,11 @@ impl ConfigVisitor for AplConfigVisitor { tag: &str, yaml: &serde_yaml::Value, ) -> Result<(), VisitorError> { + let source = format!("global.policies.{}.apl", tag); + reject_legacy_apl_keys(&source, yaml)?; let Some(apl_block) = apl_subblock(yaml) else { return Ok(()); }; - let source = format!("global.policies.{}.apl", tag); warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block); let compiled = compile_policy_block_value(&source, &apl_block) .map_err(|e| Box::new(e) as VisitorError)?; @@ -446,6 +450,7 @@ impl ConfigVisitor for AplConfigVisitor { // Extract the route's APL block (if any) and the entity identity // we need for annotate_route. A route without an APL block AND // without inherited layers contributes nothing — skip. + reject_legacy_apl_keys("route", yaml)?; let route_apl = apl_subblock(yaml); let (entity_type, entity_names) = match entity_identity(parsed) { Some(e) => e, @@ -781,6 +786,40 @@ fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) { /// [`warn_if_global_only_key_at_nonglobal_scope`]. const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 2] = ["pdp", "session_store"]; +/// Legacy APL config keys, mapped to their replacements. The flat-key path +/// in [`apl_subblock`] only copies recognized keys into the synthetic block, +/// so a config still using an old name would otherwise be *silently dropped* +/// here — a fail-open for `policy` / `post_policy`. We reject them loudly. +/// (The `apl:`-wrapped form is caught downstream by apl-core instead.) +const RENAMED_APL_KEYS: [(&str, &str); 2] = [ + ( + "policy", + "authorization.pre_invocation (or flat pre_invocation)", + ), + ( + "post_policy", + "authorization.post_invocation (or flat post_invocation)", + ), +]; + +/// Fail loudly when a section carries a renamed legacy APL key directly +/// (flat form). Guards the fail-open where the flat-key filter in +/// [`apl_subblock`] would otherwise drop an unrecognized `policy:` block. +fn reject_legacy_apl_keys(scope: &str, yaml: &serde_yaml::Value) -> Result<(), VisitorError> { + let Some(map) = yaml.as_mapping() else { + return Ok(()); + }; + for (old, new) in RENAMED_APL_KEYS { + if map.contains_key(serde_yaml::Value::String(old.to_string())) { + return Err(format!( + "in `{scope}`: config field `{old}` was renamed to `{new}` — update your config", + ) + .into()); + } + } + Ok(()) +} + /// Strip the global-only wiring sub-keys ([`GLOBAL_ONLY_NON_DSL_KEYS`]) /// from an `apl:` mapping so the remainder can be handed to /// `compile_policy_block_value` (which doesn't model PDP / session-store @@ -823,9 +862,14 @@ fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String { /// `plugins` is intentionally absent here — it is shape-ambiguous (a /// structural plugin-ref *list* vs an apl-override *map*) and handled /// separately in [`apl_subblock`]. -const FLAT_APL_KEYS: [&str; 6] = [ - "policy", - "post_policy", +/// +/// `authorization` is the nested `{ pre_invocation, post_invocation }` +/// block; it is copied through verbatim and un-nested by apl-core's +/// `compile_policy_block_value`, so nesting lives in exactly one place. +const FLAT_APL_KEYS: [&str; 7] = [ + "pre_invocation", + "post_invocation", + "authorization", "args", "result", "pdp", @@ -834,9 +878,9 @@ const FLAT_APL_KEYS: [&str; 6] = [ /// Pull a section's APL block out of its raw YAML. /// -/// The explicit `apl:` wrapper (`route -> apl -> policy`) takes +/// The explicit `apl:` wrapper (`route -> apl -> authorization`) takes /// precedence. When it is absent, APL terms written directly on the -/// section (`route -> policy`) are accepted too: a synthetic block is +/// section (`route -> authorization`) are accepted too: a synthetic block is /// assembled from the recognized [`FLAT_APL_KEYS`] present on the /// container, plus `plugins` when (and only when) it is a *mapping* — /// the apl-override shape. A structural `plugins:` *list* @@ -890,11 +934,11 @@ mod tests { #[test] fn apl_wrapper_is_returned_as_is() { - let v = yaml("apl:\n policy:\n - \"deny\"\n"); + let v = yaml("apl:\n pre_invocation:\n - \"deny\"\n"); let block = apl_subblock(&v).expect("wrapper present"); assert!( - block.get("policy").is_some(), - "wrapper block exposes policy" + block.get("pre_invocation").is_some(), + "wrapper block exposes pre_invocation" ); } @@ -908,12 +952,12 @@ mod tests { } #[test] - fn flat_policy_without_wrapper_is_collected() { - let v = yaml("tool: get_weather\npolicy:\n - \"deny\"\n"); - let block = apl_subblock(&v).expect("flat policy recognized"); + fn flat_pre_invocation_without_wrapper_is_collected() { + let v = yaml("tool: get_weather\npre_invocation:\n - \"deny\"\n"); + let block = apl_subblock(&v).expect("flat pre_invocation recognized"); assert!( - block.get("policy").is_some(), - "flat policy lifted into the block" + block.get("pre_invocation").is_some(), + "flat pre_invocation lifted into the block" ); assert!( block.get("tool").is_none(), @@ -965,15 +1009,15 @@ mod tests { #[test] fn explicit_wrapper_wins_over_flat_keys() { - let v = yaml("apl:\n policy:\n - \"allow\"\npolicy:\n - \"deny\"\n"); + let v = yaml("apl:\n pre_invocation:\n - \"allow\"\npre_invocation:\n - \"deny\"\n"); let block = apl_subblock(&v).expect("wrapper present"); - let policy = block - .get("policy") + let pre_invocation = block + .get("pre_invocation") .and_then(|p| p.as_sequence()) - .expect("policy sequence"); - assert_eq!(policy.len(), 1); + .expect("pre_invocation sequence"); + assert_eq!(pre_invocation.len(), 1); assert_eq!( - policy[0].as_str(), + pre_invocation[0].as_str(), Some("allow"), "the explicit apl wrapper takes precedence over flat top-level keys", ); @@ -986,9 +1030,10 @@ mod tests { // either global-only wiring key (`pdp` / `session_store`), or for // none present. (The drop semantics are exercised end-to-end; here // we just guard the helper's contract.) - let with_pdp = yaml("policy:\n - \"deny\"\npdp:\n - kind: cel\n"); - let with_session_store = yaml("policy:\n - \"deny\"\nsession_store:\n kind: valkey\n"); - let without = yaml("policy:\n - \"deny\"\n"); + let with_pdp = yaml("pre_invocation:\n - \"deny\"\npdp:\n - kind: cel\n"); + let with_session_store = + yaml("pre_invocation:\n - \"deny\"\nsession_store:\n kind: valkey\n"); + let without = yaml("pre_invocation:\n - \"deny\"\n"); warn_if_global_only_key_at_nonglobal_scope("route", &with_pdp); warn_if_global_only_key_at_nonglobal_scope("routes.tool", &with_session_store); warn_if_global_only_key_at_nonglobal_scope("global.defaults.tool.apl", &without); @@ -997,12 +1042,12 @@ mod tests { #[test] fn unreferenced_plugin_override_is_detectable_and_lint_is_safe() { use super::{compile_policy_block_value, warn_unreferenced_plugin_overrides}; - // A route configures two plugins but its policy only activates one: + // A route configures two plugins but its pre_invocation only activates one: // `used` is referenced by a `plugin(...)` step, `unused` is only // configured. The lint relies on `collect_plugin_names` seeing the // referenced set; verify that linkage, then that the helper runs. let block = yaml( - "policy:\n - \"plugin(used)\"\n\ + "pre_invocation:\n - \"plugin(used)\"\n\ plugins:\n used:\n on_error: ignore\n unused:\n on_error: ignore\n", ); let route = compile_policy_block_value("test", &block).expect("compiles"); @@ -1010,7 +1055,7 @@ mod tests { let referenced = crate::dispatch_plan::collect_plugin_names(&route); assert!( referenced.contains(&"used".to_string()), - "policy step is referenced" + "pre_invocation step is referenced" ); assert!( !referenced.contains(&"unused".to_string()), diff --git a/crates/apl-cpex/tests/capability_gating.rs b/crates/apl-cpex/tests/capability_gating.rs index eaaa36e6..9845889d 100644 --- a/crates/apl-cpex/tests/capability_gating.rs +++ b/crates/apl-cpex/tests/capability_gating.rs @@ -187,7 +187,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(label-reader)" "#; @@ -248,7 +248,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(label-reader)" "#; @@ -308,7 +308,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(label-writer)" "#; @@ -364,7 +364,7 @@ plugins: [] routes: - tool: get_weather apl: - policy: + pre_invocation: - "require(authenticated)" "#; let mgr = Arc::new(PluginManager::default()); @@ -406,7 +406,7 @@ plugins: [] routes: - tool: get_weather apl: - policy: + pre_invocation: - "require(authenticated)" "#; let mgr = Arc::new(PluginManager::default()); diff --git a/crates/apl-cpex/tests/config_override.rs b/crates/apl-cpex/tests/config_override.rs index 3101ad18..ef20dab2 100644 --- a/crates/apl-cpex/tests/config_override.rs +++ b/crates/apl-cpex/tests/config_override.rs @@ -188,7 +188,7 @@ plugins: routes: - tool: tool_a apl: - policy: + pre_invocation: - "plugin(gate)" - tool: tool_b apl: @@ -196,7 +196,7 @@ routes: gate: config: allowlist: ["open"] - policy: + pre_invocation: - "plugin(gate)" "#; let (mgr, instance_count) = build_manager(YAML).await; @@ -265,7 +265,7 @@ routes: gate: config: allowlist: ["open"] - policy: + pre_invocation: - "plugin(gate)" "#; let (mgr, instance_count) = build_manager(YAML).await; @@ -313,7 +313,7 @@ routes: plugins: gate: on_error: ignore - policy: + pre_invocation: - "plugin(gate)" "#; let (mgr, instance_count) = build_manager(YAML).await; @@ -365,7 +365,7 @@ routes: gate: config: allowlist: ["alpha"] - policy: + pre_invocation: - "plugin(gate)" - tool: tool_b apl: @@ -373,7 +373,7 @@ routes: gate: config: allowlist: ["open"] - policy: + pre_invocation: - "plugin(gate)" "#; let (mgr, instance_count) = build_manager(YAML).await; diff --git a/crates/apl-cpex/tests/delegate_step_e2e.rs b/crates/apl-cpex/tests/delegate_step_e2e.rs index 7a1dfc91..05e87ae8 100644 --- a/crates/apl-cpex/tests/delegate_step_e2e.rs +++ b/crates/apl-cpex/tests/delegate_step_e2e.rs @@ -296,7 +296,7 @@ plugins: hooks: [token.delegate] routes: get_compensation: - policy: + pre_invocation: - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - "!delegation.granted: deny" - "!(delegation.granted.permissions contains 'read_compensation'): deny" @@ -422,7 +422,7 @@ plugins: hooks: [token.delegate] routes: get_compensation: - policy: + pre_invocation: - "delegate(workday-oauth, target: workday-api, permissions: [write_everything])" "#; let (mgr, cfg, cache) = build_setup( @@ -517,7 +517,7 @@ plugins: hooks: [token.delegate] routes: any: - policy: + pre_invocation: - "delegate(audit-receipt, target: audit, on_error: continue)" "#; let (mgr, cfg, cache) = build_setup( @@ -619,7 +619,7 @@ plugins: hooks: [token.delegate] routes: fanout: - policy: + pre_invocation: - "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" @@ -744,7 +744,7 @@ plugins: hooks: [token.delegate] routes: get_compensation: - policy: + pre_invocation: - "delegate(scoped-delegate, target: workday-api, permissions: [read_compensation])" "#; let (mgr, cfg, cache) = build_setup( @@ -851,7 +851,7 @@ plugins: hooks: [token.delegate] routes: any: - policy: + pre_invocation: - "delegate(capless-delegate, target: workday-api)" "#; let (mgr, cfg, cache) = build_setup( diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs index a3cbf019..1723bc3e 100644 --- a/crates/apl-cpex/tests/end_to_end_route.rs +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -218,7 +218,7 @@ plugins: hooks: [cmf.tool_pre_invoke] routes: get_weather: - policy: + pre_invocation: - "plugin(scope-gate)" "#; @@ -269,7 +269,7 @@ plugins: hooks: [cmf.tool_pre_invoke] routes: get_weather: - policy: + pre_invocation: - "plugin(scope-gate)" "#; @@ -395,7 +395,7 @@ plugins: capabilities: [append_labels, read_labels] routes: classify: - policy: + pre_invocation: - "plugin(tagger)" "#; @@ -482,7 +482,7 @@ plugins: capabilities: [append_labels, read_labels] routes: classify: - policy: + pre_invocation: - "plugin(tagger)" "#; let cfg = compile_config(yaml).expect("compile_config"); @@ -548,7 +548,7 @@ async fn apl_taint_step_lands_in_security_labels_and_persists() { const YAML: &str = r#" routes: classify: - policy: + pre_invocation: - "taint(audit, session)" "#; @@ -664,7 +664,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(tagger)" "#; @@ -770,7 +770,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(tagger)" - "plugin(scope-gate)" "#; @@ -905,7 +905,7 @@ global: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(tagger)" "#; diff --git a/crates/apl-cpex/tests/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs index a0d23ee5..a17af0e8 100644 --- a/crates/apl-cpex/tests/visitor_e2e.rs +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -205,7 +205,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -239,7 +239,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(deny-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -281,12 +281,12 @@ plugins: hooks: [cmf.tool_pre_invoke] global: apl: - policy: + pre_invocation: - "plugin(allow-gate)" routes: - tool: get_weather apl: - policy: + pre_invocation: - "plugin(deny-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -318,7 +318,7 @@ global: policies: pii: apl: - policy: + pre_invocation: - "plugin(deny-gate)" routes: - tool: get_weather @@ -364,11 +364,11 @@ routes: meta: scope: vs-a apl: - policy: + pre_invocation: - "plugin(deny-gate)" - tool: get_weather apl: - policy: + pre_invocation: - "plugin(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -468,7 +468,7 @@ plugins: routes: - llm: gpt-4 apl: - policy: + pre_invocation: - "plugin(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -501,7 +501,7 @@ plugins: routes: - llm: gpt-4 apl: - post_policy: + post_invocation: - "plugin(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -532,7 +532,7 @@ plugins: routes: - prompt: summarize_email apl: - policy: + pre_invocation: - "plugin(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -563,7 +563,7 @@ plugins: routes: - resource: hr://employees/* apl: - policy: + pre_invocation: - "plugin(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -607,7 +607,7 @@ plugins: routes: - llm: gpt-4 apl: - policy: + pre_invocation: - "plugin(deny-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -651,7 +651,7 @@ plugins: routes: - tool: get_weather apl: - policy: + pre_invocation: - "this-is-not-a-valid-step ::: $$$" "#; let mgr = Arc::new(PluginManager::default()); @@ -669,7 +669,7 @@ routes: ); } -/// Flat form: a route may declare `policy:` directly, without the `apl:` +/// Flat form: a route may declare `pre_invocation:` directly, without the `apl:` /// wrapper. The visitor recognizes it identically to the wrapped form. /// (Also exercises the `run(...)` plugin alias.) #[tokio::test] @@ -681,7 +681,7 @@ plugins: hooks: [cmf.tool_pre_invoke] routes: - tool: get_weather - policy: + pre_invocation: - "run(allow-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -701,7 +701,7 @@ routes: ); } -/// Flat form deny mirrors the wrapped deny path — the route's `policy:` +/// Flat form deny mirrors the wrapped deny path — the route's `pre_invocation:` /// is honored without an `apl:` wrapper and the violation propagates. #[tokio::test] async fn visitor_flat_route_without_apl_wrapper_denies() { @@ -712,7 +712,7 @@ plugins: hooks: [cmf.tool_pre_invoke] routes: - tool: get_weather - policy: + pre_invocation: - "plugin(deny-gate)" "#; let mgr = build_manager_with_visitor(YAML).await; @@ -742,7 +742,7 @@ routes: // map through the real `load_config_yaml` path the unit tests can't hit. // ===================================================================== -/// A route with a flat `policy:` AND a flat `plugins:` *map* override +/// A route with a flat `pre_invocation:` AND a flat `plugins:` *map* override /// (no `apl:` wrapper) loads through `load_config_yaml` (previously a /// hard `invalid type: map, expected a sequence` error) and the policy /// still fires — proving the override map and the activating policy @@ -756,7 +756,7 @@ plugins: hooks: [cmf.tool_pre_invoke] routes: - tool: get_weather - policy: + pre_invocation: - "plugin(deny-gate)" plugins: deny-gate: @@ -848,7 +848,7 @@ plugins: global: defaults: tool: - policy: + pre_invocation: - "plugin(deny-gate)" plugins: deny-gate: diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index b4e87f3c..05073c27 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -160,14 +160,19 @@ pub struct GlobalConfig { #[serde(default)] pub defaults: HashMap, - /// Global identity dispatch list. Inherited by every route as - /// the first layer of identity resolution. Routes can append - /// to it (additive, the default) or replace it (with - /// `identity.replace_inherited: true` on the route). + /// Global authentication dispatch list (YAML key `authentication:`). + /// Inherited by every route as the first layer of identity + /// resolution. Routes can append to it (additive, the default) or + /// replace it (with `authentication.replace_inherited: true` on the + /// route). /// - /// Same YAML shape as the route-level `identity:` block — see + /// Same YAML shape as the route-level `authentication:` block — see /// `RouteEntry.identity` for the accepted forms. - #[serde(default, deserialize_with = "deserialize_route_identity")] + #[serde( + default, + rename = "authentication", + deserialize_with = "deserialize_route_identity" + )] pub identity: Option, } @@ -192,12 +197,16 @@ pub struct PolicyGroup { #[serde(default, deserialize_with = "deserialize_plugin_refs")] pub plugins: Vec, - /// Identity dispatch list contributed by this tag bundle. - /// Inherited by routes that carry this tag in `meta.tags`, - /// stacked between the global identity (first) and the route's - /// own identity (last). Same YAML shape as the route-level - /// `identity:` block. - #[serde(default, deserialize_with = "deserialize_route_identity")] + /// Authentication dispatch list contributed by this tag bundle + /// (YAML key `authentication:`). Inherited by routes that carry this + /// tag in `meta.tags`, stacked between the global authentication + /// (first) and the route's own authentication (last). Same YAML shape + /// as the route-level `authentication:` block. + #[serde( + default, + rename = "authentication", + deserialize_with = "deserialize_route_identity" + )] pub identity: Option, } @@ -324,29 +333,33 @@ pub struct RouteEntry { #[serde(default, deserialize_with = "deserialize_plugin_refs")] pub plugins: Vec, - /// Identity-resolve dispatch list for this route. **Hook-specific**: - /// applies ONLY to the `identity.resolve` hook, independent of the - /// `plugins:` block above (which is hook-agnostic and means - /// different things depending on whether APL is annotating the - /// route — `identity:` always means "these plugins fire on - /// identity.resolve in this order"). + /// Authentication dispatch list for this route (YAML key + /// `authentication:`). **Hook-specific**: applies ONLY to the + /// `identity.resolve` hook, independent of the `plugins:` block above + /// (which is hook-agnostic and means different things depending on + /// whether APL is annotating the route — `authentication:` always + /// means "these plugins fire on identity.resolve in this order"). /// /// Accepts two YAML shapes; both deserialize to the same IR. /// See `crate::identity::route_config::RouteIdentityConfig`. /// /// ```yaml /// # List form — common case, additive default - /// identity: + /// authentication: /// - corp-jwt /// - spiffe-attestor /// /// # Object form — when the override flag is needed - /// identity: + /// authentication: /// replace_inherited: true /// steps: /// - legacy-basic-auth /// ``` - #[serde(default, deserialize_with = "deserialize_route_identity")] + #[serde( + default, + rename = "authentication", + deserialize_with = "deserialize_route_identity" + )] pub identity: Option, } @@ -354,7 +367,7 @@ pub struct RouteEntry { // Custom Deserialize for RouteEntry.identity // --------------------------------------------------------------------------- -/// Deserialize `identity:` in a `RouteEntry`. Accepts either a YAML +/// Deserialize the `authentication:` block in a `RouteEntry`. Accepts either a YAML /// list (treated as additive — `replace_inherited: false`) or a /// YAML map with `replace_inherited: bool?` + `steps: [...]`. Each /// step is either a bare plugin name (string) or a map with @@ -391,19 +404,19 @@ where .get(serde_yaml::Value::String("steps".to_string())) .ok_or_else(|| { D::Error::custom( - "`identity:` object form requires `steps:` (a list of \ - identity steps); did you mean to write the list form?", + "`authentication:` object form requires `steps:` (a list of \ + authentication steps); did you mean to write the list form?", ) })?; let items = steps_val .as_sequence() - .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))? + .ok_or_else(|| D::Error::custom("`authentication.steps` must be a list"))? .clone(); (replace_inherited, items) }, _ => { return Err(D::Error::custom( - "`identity:` must be a list of steps or an object with \ + "`authentication:` must be a list of steps or an object with \ `steps:` (and optional `replace_inherited:`)", )); }, @@ -605,13 +618,58 @@ pub fn load_config(path: &Path) -> Result> { /// Parse a CPEX config from a YAML string. pub fn parse_config(yaml: &str) -> Result> { - let config: CpexConfig = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { + // Scan the raw YAML for renamed legacy keys before the typed parse: + // `RouteEntry` / `GlobalConfig` / `PolicyGroup` silently ignore unknown + // fields, so a stale `identity:` would otherwise be dropped and its + // authentication steps never run — a fail-open. + let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { + message: format!("failed to parse config YAML: {}", e), + })?; + reject_renamed_identity_key(&raw)?; + let config: CpexConfig = serde_yaml::from_value(raw).map_err(|e| PluginError::Config { message: format!("failed to parse config YAML: {}", e), })?; validate_config(&config)?; Ok(config) } +/// Reject the pre-rename `identity:` key (now `authentication:`) at every +/// scope it could appear — `global`, `global.policies.`, +/// `global.defaults.`, and each `routes[]` entry — so a stale config +/// fails loudly rather than silently dropping its authentication steps. +fn reject_renamed_identity_key(raw: &serde_yaml::Value) -> Result<(), Box> { + fn renamed(scope: &str) -> Box { + Box::new(PluginError::Config { + message: format!( + "in `{scope}`: config field `identity` was renamed to `authentication` — update your config" + ), + }) + } + if let Some(global) = raw.get("global") { + if global.get("identity").is_some() { + return Err(renamed("global")); + } + for section in ["policies", "defaults"] { + if let Some(map) = global.get(section).and_then(|m| m.as_mapping()) { + for (name, group) in map { + if group.get("identity").is_some() { + let n = name.as_str().unwrap_or("?"); + return Err(renamed(&format!("global.{section}.{n}"))); + } + } + } + } + } + if let Some(routes) = raw.get("routes").and_then(|r| r.as_sequence()) { + for (i, route) in routes.iter().enumerate() { + if route.get("identity").is_some() { + return Err(renamed(&format!("routes[{i}]"))); + } + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- @@ -806,9 +864,10 @@ pub fn resolve_plugins_for_entity( /// Resolve the identity-resolve dispatch list for a specific /// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`] -/// — consults `global.identity`, tag-bundle `identity` blocks, and -/// the route's own `identity:` block to determine which plugins fire -/// on the `identity.resolve` hook for this route. +/// — consults the global `authentication:` block, tag-bundle +/// `authentication:` blocks, and the route's own `authentication:` block +/// to determine which plugins fire on the `identity.resolve` hook for +/// this route. /// /// # Inheritance / merge order /// @@ -1619,7 +1678,7 @@ routes: assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); } - // ---- route-level `identity:` block ---- + // ---- route-level `authentication:` block ---- #[test] fn parse_route_identity_list_form() { @@ -1629,7 +1688,7 @@ plugins: - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } routes: - tool: get_weather - identity: + authentication: - corp-jwt - spiffe-attestor "#; @@ -1651,7 +1710,7 @@ plugins: - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } routes: - tool: legacy - identity: + authentication: replace_inherited: true steps: - legacy-basic-auth @@ -1670,7 +1729,7 @@ plugins: - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } routes: - tool: get_weather - identity: + authentication: - name: corp-jwt on_error: deny config: @@ -1696,7 +1755,7 @@ plugins: - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } routes: - tool: get_weather - identity: + authentication: - name: corp-jwt on_error: deny - spiffe-attestor @@ -1713,7 +1772,7 @@ routes: let yaml = r#" routes: - tool: bad - identity: + authentication: replace_inherited: true "#; let err = parse_config(yaml).expect_err("object form requires steps"); @@ -1726,7 +1785,7 @@ routes: let yaml = r#" routes: - tool: bad - identity: + authentication: replace_inherited: "yes" steps: - corp-jwt @@ -1741,7 +1800,7 @@ routes: let yaml = r#" routes: - tool: bad - identity: + authentication: - "" "#; let err = parse_config(yaml).expect_err("empty step name should fail"); @@ -1749,12 +1808,32 @@ routes: assert!(msg.contains("empty"), "got: {msg}"); } + #[test] + fn legacy_identity_key_is_rejected_at_route_and_global() { + // Breaking rename: `identity:` was renamed to `authentication:`. + // A stale key must fail loudly, never be silently dropped (which + // would skip authentication — a fail-open). + for yaml in [ + "routes:\n - tool: t\n identity:\n - corp-jwt\n", + "global:\n identity:\n - corp-jwt\n", + "global:\n policies:\n all:\n identity:\n - corp-jwt\n", + "global:\n defaults:\n tool:\n identity:\n - corp-jwt\n", + ] { + let err = parse_config(yaml).expect_err("legacy identity: must be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("identity") && msg.contains("authentication"), + "rejection should name the rename: {msg}" + ); + } + } + #[test] fn parse_route_identity_scalar_shape_errors() { let yaml = r#" routes: - tool: bad - identity: 42 + authentication: 42 "#; let err = parse_config(yaml).expect_err("scalar identity should fail"); let msg = format!("{err}"); @@ -1770,7 +1849,7 @@ plugins: - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } routes: - tool: get_weather - identity: + authentication: - corp-jwt "#; let cfg = parse_config(yaml).unwrap(); @@ -1802,7 +1881,7 @@ plugins: - { name: agent-context, kind: builtin, hooks: [identity.resolve] } routes: - tool: get_weather - identity: + authentication: - spiffe-attestor - corp-jwt - agent-context @@ -1824,7 +1903,7 @@ plugins: - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } routes: - tool: get_weather - identity: + authentication: - name: corp-jwt config: audience: my-tool @@ -1855,7 +1934,7 @@ plugin_settings: plugins: - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } global: - identity: + authentication: - corp-jwt routes: - tool: get_weather @@ -1878,11 +1957,11 @@ plugins: - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } - { name: agent-context, kind: builtin, hooks: [identity.resolve] } global: - identity: + authentication: - corp-jwt routes: - tool: get_weather - identity: + authentication: - agent-context "#; let cfg = parse_config(yaml).unwrap(); @@ -1904,17 +1983,17 @@ plugins: - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } - { name: agent-context, kind: builtin, hooks: [identity.resolve] } global: - identity: + authentication: - corp-jwt policies: finance: - identity: + authentication: - workday-saml routes: - tool: get_compensation meta: tags: [finance] - identity: + authentication: - agent-context "#; let cfg = parse_config(yaml).unwrap(); @@ -1935,17 +2014,17 @@ plugins: - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } global: - identity: + authentication: - corp-jwt policies: finance: - identity: + authentication: - workday-saml routes: - tool: legacy_endpoint meta: tags: [finance] - identity: + authentication: replace_inherited: true steps: - legacy-basic-auth @@ -1967,11 +2046,11 @@ plugin_settings: plugins: - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } global: - identity: + authentication: - corp-jwt routes: - tool: anonymous_endpoint - identity: + authentication: replace_inherited: true steps: [] "#; @@ -1992,7 +2071,7 @@ plugins: global: policies: finance: - identity: + authentication: - workday-saml routes: - tool: with_tag @@ -2028,7 +2107,7 @@ routes: - tool: get_weather meta: scope: tenant-a - identity: + authentication: - corp-jwt "#; let cfg = parse_config(yaml).unwrap(); diff --git a/crates/cpex-core/src/hooks/metadata.rs b/crates/cpex-core/src/hooks/metadata.rs index cc501740..e0921f32 100644 --- a/crates/cpex-core/src/hooks/metadata.rs +++ b/crates/cpex-core/src/hooks/metadata.rs @@ -22,8 +22,8 @@ // // 1. **Multi-hook bug.** Two step-context hooks on the same plugin // (pre + post) collapsed to "first non-field wins" — silent -// wrong dispatch when policy and post_policy needed different -// entries. +// wrong dispatch when pre_invocation and post_invocation needed +// different entries. // 2. **The "field-hook" classification didn't match any real hook.** // No CMF hook actually carries `field` / `redact` / `scan` / // `validate` in its name — the heuristic was anticipating a @@ -51,13 +51,13 @@ // // APL phases map to hook phases: // -// * `args:` field stage → looks for `Pre` hooks -// * `policy:` step → looks for `Pre` hooks -// * `result:` field stage → looks for `Post` hooks -// * `post_policy:` step → looks for `Post` hooks +// * `args:` field stage → looks for `Pre` hooks +// * `pre_invocation:` step → looks for `Pre` hooks +// * `result:` field stage → looks for `Post` hooks +// * `post_invocation:` step → looks for `Post` hooks // // A plugin that wants to discriminate "args field stage" from -// "policy step" — both Pre context — inspects `PluginContext::hook_name()` +// "pre_invocation step" — both Pre context — inspects `PluginContext::hook_name()` // itself. The hook-routing layer doesn't slice phase finer than // Pre/Post. // @@ -84,20 +84,20 @@ use crate::identity::HOOK_IDENTITY_RESOLVE; /// Lifecycle position a hook occupies for dispatcher purposes. /// -/// APL's args/policy phases dispatch to `Pre` hooks; APL's -/// result/post_policy phases dispatch to `Post` hooks. Hook families +/// APL's args/pre_invocation phases dispatch to `Pre` hooks; APL's +/// result/post_invocation phases dispatch to `Post` hooks. Hook families /// outside the request-lifecycle model (identity at request entry, -/// token-delegate inside policy) use `Unphased` and match any +/// token-delegate inside authorization) use `Unphased` and match any /// requested phase. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HookPhase { /// Pre-invocation hook — e.g. `cmf.tool_pre_invoke`, /// `cmf.llm_input`. Dispatched from APL's `args:` field stages - /// and `policy:` steps. + /// and `pre_invocation:` steps. Pre, /// Post-invocation hook — e.g. `cmf.tool_post_invoke`, /// `cmf.llm_output`. Dispatched from APL's `result:` field stages - /// and `post_policy:` steps. + /// and `post_invocation:` steps. Post, /// Not phase-bound. Covers hook families that fire once per /// request without an APL phase concept (`identity.resolve`, diff --git a/crates/cpex-core/tests/identity_route_e2e.rs b/crates/cpex-core/tests/identity_route_e2e.rs index df26f892..4ed024ba 100644 --- a/crates/cpex-core/tests/identity_route_e2e.rs +++ b/crates/cpex-core/tests/identity_route_e2e.rs @@ -3,16 +3,16 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// End-to-end tests for the route-level `identity:` block (Slice A). +// End-to-end tests for the route-level `authentication:` block (Slice A). // // Verifies the hook-specific binding semantics: -// * A route's `identity:` block is the authoritative dispatch list +// * A route's `authentication:` block is the authoritative dispatch list // for the `identity.resolve` hook on that route. // * The route's `plugins:` block (which means "per-route overrides" // in APL-driven routes, "per-route binding" otherwise) does NOT // bind plugins for the `identity.resolve` hook. // * Dispatch order matches the order steps are declared in -// `identity:`, NOT the plugins' chain-priority values. +// `authentication:`, NOT the plugins' chain-priority values. // * Per-step config overrides flow through the existing // `create_override_instance` pathway. // @@ -233,7 +233,7 @@ fn manager_with_observing_factory() -> ( // Scenarios // ===================================================================== -/// Baseline: route's `identity:` block dispatches the listed plugins, +/// Baseline: route's `authentication:` block dispatches the listed plugins, /// in declared order, for `identity.resolve`. The ledger should /// reflect the YAML order verbatim — proves the per-route binding + /// preserved order story end-to-end. @@ -243,7 +243,7 @@ async fn route_identity_block_dispatches_in_declared_order() { // Three identity plugins, all registered under `identity.resolve`. // Route declares them in REVERSE priority order to prove that - // routing follows the `identity:` declaration, not chain priority. + // routing follows the `authentication:` declaration, not chain priority. let yaml = r#" plugin_settings: routing_enabled: true @@ -263,7 +263,7 @@ plugins: routes: - tool: get_weather - identity: + authentication: - jwt-c # priority 30 — would naturally run LAST in chain order - jwt-a # priority 10 — would naturally run FIRST - jwt-b # priority 20 @@ -287,25 +287,25 @@ routes: result.violation, ); - // Order matches the YAML's `identity:` declaration, NOT plugin priority. + // Order matches the YAML's `authentication:` declaration, NOT plugin priority. let firings = ledger.lock().unwrap().clone(); assert_eq!(firings, vec!["jwt-c", "jwt-a", "jwt-b"]); } -/// `identity:` is hook-specific. Plugins in the route's `plugins:` +/// `authentication:` is hook-specific. Plugins in the route's `plugins:` /// block (which means "per-route overrides" in APL-driven routes /// and "per-route binding" otherwise) must NOT fire for the /// identity.resolve hook. This is the load-bearing test for -/// Option 1 — the design decision that `identity:` is its own +/// Option 1 — the design decision that `authentication:` is its own /// dispatch list, independent of `plugins:`. #[tokio::test] async fn route_plugins_block_does_not_bind_identity_resolve() { let (mgr, ledger, _) = manager_with_recording_factory(); - // The route declares `identity:` with corp-jwt, and `plugins:` + // The route declares `authentication:` with corp-jwt, and `plugins:` // with rogue-jwt. rogue-jwt also registers under identity.resolve // — but should NOT fire for the identity.resolve hook on this - // route because it's listed in `plugins:`, not `identity:`. + // route because it's listed in `plugins:`, not `authentication:`. let yaml = r#" plugin_settings: routing_enabled: true @@ -319,7 +319,7 @@ plugins: routes: - tool: get_weather - identity: + authentication: - corp-jwt plugins: - rogue-jwt @@ -339,11 +339,11 @@ routes: assert!(result.continue_processing); // Only corp-jwt fired — rogue-jwt was in `plugins:`, not - // `identity:`, so it's NOT bound for this hook on this route. + // `authentication:`, so it's NOT bound for this hook on this route. assert_eq!(ledger.lock().unwrap().clone(), vec!["corp-jwt"]); } -/// A route with no `identity:` block produces zero identity +/// A route with no `authentication:` block produces zero identity /// dispatches even when the entity_type / entity_name match. The /// plugins ARE registered under identity.resolve, but no route /// binds them, so the route-filter returns an empty entry list. @@ -379,7 +379,7 @@ routes: .await; assert!(result.continue_processing); - // No identity plugins fired — `identity:` was absent, so the + // No identity plugins fired — `authentication:` was absent, so the // route binds nothing for the identity.resolve hook even though // corp-jwt is in `plugins:`. assert!(ledger.lock().unwrap().is_empty()); @@ -402,7 +402,7 @@ plugins: routes: - tool: get_compensation - identity: + authentication: - corp-jwt "#; let parsed = config::parse_config(yaml).expect("parse"); @@ -447,7 +447,7 @@ plugins: routes: - tool: get_weather - identity: + authentication: - name: corp-jwt config: audience: route-specific-aud @@ -494,7 +494,7 @@ plugins: hooks: [identity.resolve] global: - identity: + authentication: - corp-jwt routes: @@ -542,18 +542,18 @@ plugins: hooks: [identity.resolve] global: - identity: + authentication: - corp-jwt policies: finance: - identity: + authentication: - workday-saml routes: - tool: get_compensation meta: tags: [finance] - identity: + authentication: - agent-context "#; let parsed = cpex_core::config::parse_config(yaml).expect("parse"); @@ -599,18 +599,18 @@ plugins: hooks: [identity.resolve] global: - identity: + authentication: - corp-jwt policies: finance: - identity: + authentication: - workday-saml routes: - tool: legacy_endpoint meta: tags: [finance] - identity: + authentication: replace_inherited: true steps: - legacy-basic-auth @@ -650,12 +650,12 @@ plugins: hooks: [identity.resolve] global: - identity: + authentication: - corp-jwt routes: - tool: public_endpoint - identity: + authentication: replace_inherited: true steps: [] "#; @@ -696,7 +696,7 @@ plugins: routes: - tool: get_weather - identity: + authentication: replace_inherited: true steps: [] "#; @@ -774,7 +774,7 @@ plugins: routes: - tool: get_weather - identity: + authentication: - scoped-jwt "#; let parsed = cpex_core::config::parse_config(yaml).expect("parse"); @@ -829,7 +829,7 @@ plugins: routes: - tool: get_weather - identity: + authentication: - capless-jwt "#; let parsed = cpex_core::config::parse_config(yaml).expect("parse"); diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs index e4b05b72..b8272cfb 100644 --- a/crates/cpex-ffi/src/apl.rs +++ b/crates/cpex-ffi/src/apl.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// APL (Attribute Policy Language) FFI wiring. +// APL (Authorization Policy Language) FFI wiring. // // `cpex_apl_install` registers the bundled APL plugin factories and // installs the APL config visitor on a manager so that a subsequent diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index c6d748d8..813191fb 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -1718,8 +1718,9 @@ plugins: routes: - tool: get_weather apl: - policy: - - "plugin(auditor)" + authorization: + pre_invocation: + - "plugin(auditor)" "#; unsafe { let mgr = build_test_manager(); diff --git a/docs/content/docs/0.1.x/vision.md b/docs/content/docs/0.1.x/vision.md index 645a7d72..0671d6b4 100644 --- a/docs/content/docs/0.1.x/vision.md +++ b/docs/content/docs/0.1.x/vision.md @@ -29,7 +29,7 @@ Enforcement is a three-layer problem. |-------|------| | **Hooks** | Where enforcement happens. Interception, decision, transformation. | | **CMF** (Common Message Format) | What you evaluate. A protocol-agnostic context envelope carrying identity, security labels, delegation chains, and content. | -| **APL** (Attribute Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | +| **APL** (Authorization Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | ![Hooks, CMF, and APL form a unified enforcement stack](/cpex/images/overview_vision.png) diff --git a/docs/content/docs/apl/_index.md b/docs/content/docs/apl/_index.md index c5bd3aec..f8711865 100644 --- a/docs/content/docs/apl/_index.md +++ b/docs/content/docs/apl/_index.md @@ -23,30 +23,33 @@ Policy is organized by **route**: an operation CPEX mediates, identified by the ```mermaid flowchart LR - ARGS["args
validate / transform input"] --> POL["policy
authorize"] --> RES["result
transform output"] --> POST["post_policy
audit / final checks"] + ARGS["args
validate / transform input"] --> POL["authorization.pre_invocation
authorize"] --> RES["result
transform output"] --> POST["authorization.post_invocation
audit / final checks"] ``` - **args**: validate and transform request inputs before the operation runs. -- **policy**: authorize the operation. Predicates, PDP calls, delegation, tainting. +- **authorization.pre_invocation**: authorize the operation. Predicates, PDP calls, delegation, tainting. - **result**: transform the response. Redaction and masking on the wire. -- **post_policy**: checks after the result is known. Audit, post-delegation verification. +- **authorization.post_invocation**: checks after the result is known. Audit, post-delegation verification. -The first `deny` in any phase halts that phase and every later phase. Nothing reaches the backend after a deny in `args` or `policy`. +The first `deny` in any phase halts that phase and every later phase. Nothing reaches the backend after a deny in `args` or `authorization.pre_invocation`. ```yaml routes: - tool: get_employee args: employee_id: "str" - policy: - - "require(authenticated)" - - "delegation.depth > 2: deny" + authorization: + pre_invocation: + - "require(authenticated)" + - "delegation.depth > 2: deny" result: ssn: "str | redact(!perm.view_ssn)" salary: "int | redact(!role.hr)" employee_id: "str | mask(4)" ``` +The `pre_invocation:` / `post_invocation:` lists may also be written flat on the route (without the `authorization:` wrapper); both forms are equivalent. + ## Predicates A predicate reads attributes resolved from the caller's identity and request context (see [Identity]({{< relref "/docs/apl/identity" >}}) for where attributes come from). The forms: @@ -64,7 +67,7 @@ A predicate reads attributes resolved from the caller's identity and request con ## Rules -A `policy:` (or `post_policy:`) entry is a rule. Two forms: +A `pre_invocation:` (or `post_invocation:`) entry is a rule. Two forms: **`require(...)`** denies unless the predicate holds: @@ -119,6 +122,6 @@ Named-validator dispatch (`validate(name)`) is not implemented in the current bu ## Effects beyond predicates -A `policy:` rule can also call a PDP, mint a delegated token, or invoke a plugin. Those effects and how they sequence are covered in [Effects]({{< relref "/docs/apl/effects" >}}). +A `pre_invocation:` rule can also call a PDP, mint a delegated token, or invoke a plugin. Those effects and how they sequence are covered in [Effects]({{< relref "/docs/apl/effects" >}}). Every fragment on this page is drawn from the `apl-core` parser tests and the reference deployments, so the forms shown here parse as written. diff --git a/docs/content/docs/apl/delegation.md b/docs/content/docs/apl/delegation.md index c2cf467b..dec0db81 100644 --- a/docs/content/docs/apl/delegation.md +++ b/docs/content/docs/apl/delegation.md @@ -13,10 +13,10 @@ The scenario's `get_compensation` reads from a backend HR system that expects it ## Delegation as an effect -`delegate` is an effect in the `policy` phase. It names a delegator plugin and the target it mints for: +`delegate` is an effect in the `authorization.pre_invocation` phase. It names a delegator plugin and the target it mints for: ```yaml -policy: +pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - "delegation.granted.permissions contains 'read_compensation': allow" diff --git a/docs/content/docs/apl/effects.md b/docs/content/docs/apl/effects.md index 5a50719b..cb3a6729 100644 --- a/docs/content/docs/apl/effects.md +++ b/docs/content/docs/apl/effects.md @@ -5,7 +5,7 @@ weight: 10 # Effects and Sequencing -An APL rule does something. That something is an **effect**. Effects are the building blocks of policy: a `policy:` block is an ordered list of them, and they run in sequence until one denies. +An APL rule does something. That something is an **effect**. Effects are the building blocks of policy: a `pre_invocation:` block is an ordered list of them, and they run in sequence until one denies. ## The effects @@ -21,10 +21,10 @@ An APL rule does something. That something is an **effect**. Effects are the bui ## Sequencing and halt-on-deny -Effects in a `policy:` block run top to bottom. The first `deny` halts the phase and skips every later phase, so order is a tool: put cheap gates first and expensive effects last. +Effects in a `pre_invocation:` block run top to bottom. The first `deny` halts the phase and skips every later phase, so order is a tool: put cheap gates first and expensive effects last. ```yaml -policy: +pre_invocation: - "require(role.hr)" # cheap attribute gate - cedar: # relationship decision action: 'Action::"read"' @@ -39,7 +39,7 @@ If `require(role.hr)` denies, the Cedar call and the token exchange never run. T A PDP call can carry reaction blocks that run depending on the decision: ```yaml -policy: +pre_invocation: - cedar: action: 'Action::"read"' resource: { type: Document, id: ${args.doc_id} } @@ -56,14 +56,14 @@ policy: Effects can be grouped. `sequential` runs its members in order and halts on the first deny. `parallel` runs independent gates concurrently; any deny fails the group, and taints from the branches accumulate. ```yaml -policy: +pre_invocation: - parallel: - "require(perm.read_pii)" - cel: { expr: "subject.department == 'compliance'" } ``` -`parallel` is for independent decisions only. It rejects field operations and delegation, because a discarded branch would silently lose those effects. Use `sequential` (the default for a `policy:` list) whenever one effect depends on another. +`parallel` is for independent decisions only. It rejects field operations and delegation, because a discarded branch would silently lose those effects. Use `sequential` (the default for a `pre_invocation:` list) whenever one effect depends on another. ## Phases recap -Effects run within the four route phases: `args`, `policy`, `result`, `post_policy` (see [APL]({{< relref "/docs/apl" >}})). `delegate` and PDP calls belong in `policy` or `post_policy`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. +Effects run within the four route phases: `args`, `authorization.pre_invocation`, `result`, `authorization.post_invocation` (see [APL]({{< relref "/docs/apl" >}})). `delegate` and PDP calls belong in `pre_invocation` or `post_invocation`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. diff --git a/docs/content/docs/apl/pdp.md b/docs/content/docs/apl/pdp.md index 0a83fcdf..fe5172d8 100644 --- a/docs/content/docs/apl/pdp.md +++ b/docs/content/docs/apl/pdp.md @@ -13,10 +13,10 @@ The scenario's repository search must allow a read when the caller is an enginee ## Calling a PDP from policy -A PDP call is an effect in the `policy` phase. It names a dialect and passes the request; `on_allow` and `on_deny` react to the decision: +A PDP call is an effect in the `authorization.pre_invocation` phase. It names a dialect and passes the request; `on_allow` and `on_deny` react to the decision: ```yaml -policy: +pre_invocation: - "require(team.engineering | team.security)" - cedar: action: 'Action::"read"' @@ -65,7 +65,7 @@ This is a deliberate pluggable-resolver surface, not a maturity checklist. APL s CEL is the lightest option for inline boolean policy: ```yaml -policy: +pre_invocation: - cel: { expr: "subject.department == 'compliance' || 'admin' in subject.roles" } ``` diff --git a/docs/content/docs/apl/tainting.md b/docs/content/docs/apl/tainting.md index 8a62002b..98a1b659 100644 --- a/docs/content/docs/apl/tainting.md +++ b/docs/content/docs/apl/tainting.md @@ -18,7 +18,7 @@ A `taint` effect attaches a label. The scenario marks the session when compensat ```yaml routes: - tool: get_compensation - policy: + pre_invocation: - "require(role.hr)" - "taint(secret, session)" result: @@ -39,7 +39,7 @@ A different route, later in the same session, refuses based on the label, even w ```yaml routes: - tool: send_email - policy: + pre_invocation: - "require(perm.email_send)" - "security.labels contains \"secret\": deny('session touched secret data', 'session_tainted')" ``` diff --git a/docs/content/docs/configuration.md b/docs/content/docs/configuration.md index a8bfb031..bc09dd9a 100644 --- a/docs/content/docs/configuration.md +++ b/docs/content/docs/configuration.md @@ -25,10 +25,12 @@ global: # cross-cutting resolvers and stores routes: # APL policy, keyed by operation : + authentication: [ ... ] # identity-resolution plugins args: { ... } - policy: [ ... ] + authorization: + pre_invocation: [ ... ] + post_invocation: [ ... ] result: { ... } - post_policy: [ ... ] ``` ## Plugins @@ -91,15 +93,18 @@ Routes carry the APL policy. The map-keyed form (keyed by route name) is the can ```yaml routes: get_compensation: - policy: - - "require(role.hr)" - - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - - "taint(secret, session)" - - "plugin(audit-log)" + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" + - "plugin(audit-log)" result: ssn: "str | redact(!perm.view_ssn)" ``` -Deployment integrations that wrap CPEX (a gateway or sidecar) often express routes as a list of `- tool:` entries instead; that form carries the same `policy`/`args`/`result` blocks. See [Deployment]({{< relref "/docs/deployment" >}}) for that variant, and [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. +The two authorization phases may also be written flat — `pre_invocation:` / `post_invocation:` directly on the route — which is equivalent to nesting them under `authorization:`. + +Deployment integrations that wrap CPEX (a gateway or sidecar) often express routes as a list of `- tool:` entries instead; that form carries the same `authorization`/`args`/`result` blocks. See [Deployment]({{< relref "/docs/deployment" >}}) for that variant, and [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. Route-level overrides can adjust a plugin's `capabilities` or `config` for a specific operation, so a scanner can be granted `read_labels` on one sensitive route without widening its access everywhere. diff --git a/docs/content/docs/deployment.md b/docs/content/docs/deployment.md index e44d9acf..9dd852da 100644 --- a/docs/content/docs/deployment.md +++ b/docs/content/docs/deployment.md @@ -14,7 +14,7 @@ Take the `get_compensation` route. It is identical whether CPEX fronts the backe ```yaml routes: - tool: get_compensation - policy: + pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - "taint(secret, session)" @@ -28,7 +28,7 @@ As a **gateway**, CPEX sits in front of the tool server and enforces on inbound ## Route forms -A deployment integration usually expresses routes as a list of `- tool:` entries, with the `policy`, `args`, and `result` blocks directly under each. This is the same policy you would write in the map-keyed form (see [Configuration]({{< relref "/docs/configuration" >}})); the wrapping differs, the rules do not. Pick one form per deployment and keep it consistent. +A deployment integration usually expresses routes as a list of `- tool:` entries, with the `authorization`, `args`, and `result` blocks directly under each. This is the same policy you would write in the map-keyed form (see [Configuration]({{< relref "/docs/configuration" >}})); the wrapping differs, the rules do not. Pick one form per deployment and keep it consistent. ## Placement guidance diff --git a/docs/content/docs/overview.md b/docs/content/docs/overview.md index 309fb2c5..1f412f83 100644 --- a/docs/content/docs/overview.md +++ b/docs/content/docs/overview.md @@ -24,7 +24,7 @@ The clearest demonstration is redaction on the wire. Three callers issue the ide ```yaml routes: - tool: get_compensation - policy: + pre_invocation: - "require(role.hr)" result: ssn: "str | redact(!perm.view_ssn)" @@ -51,7 +51,7 @@ Some controls depend on what already happened. When a caller reads compensation ```yaml routes: - tool: send_email - policy: + pre_invocation: - "require(perm.email_send)" - "security.labels contains \"secret\": deny('session touched secret data', 'session_tainted')" ``` diff --git a/docs/content/docs/patterns.md b/docs/content/docs/patterns.md index 0ced64a3..6025b32e 100644 --- a/docs/content/docs/patterns.md +++ b/docs/content/docs/patterns.md @@ -12,7 +12,7 @@ Production patterns for writing and rolling out CPEX policy. Each is expressed i Order effects cheapest-gate-first so expensive work only runs for requests that survive the early checks. Attribute gates, then a PDP call, then delegation: ```yaml -policy: +pre_invocation: - "require(team.engineering | team.security)" # cheap - cedar: { action: 'Action::"read"', resource: { type: Repo, id: ${args.repo_name} } } - "delegate(github-oauth, target: github-api, permissions: [repo:read])" # expensive, last @@ -52,9 +52,9 @@ Taint a session when it touches sensitive data, then gate later operations on th ```yaml routes: get_compensation: - policy: [ "require(role.hr)", "taint(secret, session)" ] + pre_invocation: [ "require(role.hr)", "taint(secret, session)" ] send_email: - policy: + pre_invocation: - "require(perm.email_send)" - "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')" ``` @@ -64,7 +64,7 @@ routes: Declare the narrowest capabilities each plugin needs, and scope delegated tokens to the minimum. A scanner that reads content does not get identity; a downstream token gets only the scope the operation requires, verified after the exchange: ```yaml -policy: +pre_invocation: - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - "delegation.granted.permissions contains 'read_compensation': allow" # verify least privilege ``` diff --git a/docs/content/docs/quickstart.md b/docs/content/docs/quickstart.md index 1d8f5d22..d78124e3 100644 --- a/docs/content/docs/quickstart.md +++ b/docs/content/docs/quickstart.md @@ -38,7 +38,7 @@ routes: get_employee: args: employee_id: "str" - policy: + pre_invocation: - "require(authenticated)" - "require(role.hr)" result: @@ -51,7 +51,7 @@ The `require(authenticated)` and `require(role.hr)` predicates read attributes r ## 4. Run it -Load the config into the manager and dispatch operations through it. The four phases run automatically: `args` validates `employee_id`, `policy` authorizes, `result` redacts. See [`crates/cpex-core/examples`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core/examples) for runnable end-to-end programs that load a config and invoke a route. +Load the config into the manager and dispatch operations through it. The four phases run automatically: `args` validates `employee_id`, `authorization.pre_invocation` authorizes, `result` redacts. See [`crates/cpex-core/examples`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core/examples) for runnable end-to-end programs that load a config and invoke a route. The outcome matches the scenario: diff --git a/docs/content/docs/vision.md b/docs/content/docs/vision.md index f79ac180..4736e3c3 100644 --- a/docs/content/docs/vision.md +++ b/docs/content/docs/vision.md @@ -27,7 +27,7 @@ You do not write enforcement logic in application code. You write **APL**: the d ```yaml routes: - tool: get_compensation - policy: + pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" - "taint(secret, session)" diff --git a/go/cpex/apl.go b/go/cpex/apl.go index c144ee65..df97885c 100644 --- a/go/cpex/apl.go +++ b/go/cpex/apl.go @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// APL (Attribute Policy Language) wiring. +// APL (Authorization Policy Language) wiring. // // EnableAPL registers the bundled APL plugin/PDP factories and installs // the APL config visitor on the manager via the cpex_apl_install FFI