From b0afbddbd2e6030e0f5abc8f1e2276b374a60453 Mon Sep 17 00:00:00 2001 From: mkoushni Date: Tue, 1 Sep 2026 11:36:58 +0300 Subject: [PATCH] docs(cmf): settle the extensions bag contract (#18) The twelve CMF slots had an implicit per-type empty/absent rule and a claims gap that only lived next to extractors. Document the contract, assert it in the bridge, and move present-empty sets into the PDP agreement subset so APL, CEL, cedar-direct, and OPA deny the same way. Signed-off-by: mkoushni --- CHANGELOG.md | 12 +- Cargo.lock | 2 + crates/ppe-apl-cmf/src/extensions_bridge.rs | 151 ++++++++- crates/ppe-apl-cmf/src/lib.rs | 3 + crates/ppe-pdp-diff/Cargo.toml | 2 + crates/ppe-pdp-diff/README.md | 17 +- crates/ppe-pdp-diff/src/allowlist.rs | 59 ++-- crates/ppe-pdp-diff/src/cases.rs | 155 +++++++-- crates/ppe-pdp-diff/src/lib.rs | 41 ++- docs/cmf-extensions.md | 351 ++++++++++++++++++++ 10 files changed, 743 insertions(+), 50 deletions(-) create mode 100644 docs/cmf-extensions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8dce7..5b595e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added +- **`docs/cmf-extensions.md`, the bag contract.** The CMF bridge writes twelve + extension slots into a flat `AttributeBag`, and until now the empty-set + rule for `StringSet`, the original-vs-flattened role keys, and the + `subject.claims` gap lived only as comments beside the extractors. The + document is the per-type absent-value contract, which key a policy author + should write, why there is no `subject.claims` map in the bag, and a + catalog of every key each slot emits. `ppe-pdp-diff` checks that a + present-empty set Denies on APL, CEL, cedar-direct, and OPA; unguarded + probes of omitted scalars stay on the allowlist. ([#18](https://github.com/praxis-proxy/policy/issues/18)) + - **`docs/apl-grammar.md`, the grammar as a document.** APL's grammar lived in comments beside the parser, and those comments were wrong on four counts: they described steps, pipe chains, `in` / `not in` / `exists()` and @@ -47,7 +57,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). behavior. Its resolution rule changed in this release too, which the Changed section covers. -- **Differential tests across Cedar, CEL, and OPA.** The three PDP resolvers each had their own suite; nothing checked that they agree on the same `AttributeBag`. `ppe-pdp-diff` feeds one bag and an equivalent policy intent to all three and compares verdicts and cause kinds. The shared semantic subset (bool, int, string, non-empty string set) must agree. Known splits — float claims, whole-number floats, Cedar resource floats, empty sets, missing collections, missing `subject.id` — live on an allowlist with a reason. An unlisted disagreement fails `make test`. Adding a fourth builtin PDP without a harness driver fails a facade test. ([#25](https://github.com/praxis-proxy/policy/issues/25)) +- **Differential tests across Cedar, CEL, and OPA.** The three PDP resolvers each had their own suite; nothing checked that they agree on the same `AttributeBag`. `ppe-pdp-diff` feeds one bag and an equivalent policy intent to all three and compares verdicts and cause kinds. The shared semantic subset (bool, int, string, non-empty string set, present-empty string set) must agree. Known splits — float claims, whole-number floats, Cedar resource floats, missing collections, missing `subject.id`, omitted claim scalars — live on an allowlist with a reason. An unlisted disagreement fails `make test`. Adding a fourth builtin PDP without a harness driver fails a facade test. ([#25](https://github.com/praxis-proxy/policy/issues/25)) - **Delegated tokens can be reused until they expire.** The OAuth delegator runs one RFC 8693 exchange per `delegate` step; a `cache:` block lets it serve a token it already minted instead. Off unless enabled, and then only for `subject: this_workload` and `client`, whose number of cache entries is bounded by configuration rather than by the caller population. `user` and `caller_workload` are opt-in through `cache.subjects`. Concurrent requests for one uncached key produce one exchange rather than one each, and a failed exchange is not stored. A cached token stays usable after an `IdP`-side revocation until its entry retires, which `cache.ttl_ceiling_seconds` bounds. ([#30](https://github.com/praxis-proxy/policy/issues/30)) diff --git a/Cargo.lock b/Cargo.lock index c6d0f5d..2d0fd9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2435,7 +2435,9 @@ dependencies = [ name = "praxis-policy-pdp-diff" version = "0.1.0" dependencies = [ + "praxis-policy-apl-cmf", "praxis-policy-apl-core", + "praxis-policy-core", "praxis-policy-pdp-cedar-direct", "praxis-policy-pdp-cel", "praxis-policy-pdp-opa", diff --git a/crates/ppe-apl-cmf/src/extensions_bridge.rs b/crates/ppe-apl-cmf/src/extensions_bridge.rs index 702a859..3ab1c29 100644 --- a/crates/ppe-apl-cmf/src/extensions_bridge.rs +++ b/crates/ppe-apl-cmf/src/extensions_bridge.rs @@ -20,6 +20,11 @@ use crate::{ }; /// Flatten every present slot in `Extensions` into `bag`. +/// +/// An absent slot writes nothing. A present slot follows the per-type +/// absent-value contract in `docs/cmf-extensions.md`: `StringSet` keys are +/// present-empty, optional scalars are omitted, flattened member booleans +/// are presence-only. pub fn extract_extensions(ext: &Extensions, bag: &mut AttributeBag) { if let Some(v) = &ext.security { extract_security(v, bag); @@ -73,10 +78,12 @@ pub fn extract_extensions(ext: &Extensions, bag: &mut AttributeBag) { mod tests { use super::*; use praxis_policy_core::extensions::{ - AgentExtension, DelegationExtension, LLMExtension, MetaExtension, SecurityExtension, - SubjectExtension, + AgentExtension, ClientExtension, CompletionExtension, ConversationContext, + DelegationExtension, FrameworkExtension, HttpExtension, LLMExtension, MCPExtension, + MetaExtension, ProvenanceExtension, RequestExtension, SecurityExtension, SubjectExtension, + WorkloadIdentity, }; - use std::collections::HashSet; + use std::collections::{HashMap, HashSet}; use std::sync::Arc; #[test] @@ -123,4 +130,142 @@ mod tests { extract_extensions(&ext, &mut bag); assert!(bag.is_empty()); } + + fn empty() -> HashSet { + HashSet::new() + } + + fn bag_of(ext: Extensions) -> AttributeBag { + let mut bag = AttributeBag::new(); + extract_extensions(&ext, &mut bag); + bag + } + + /// The per-type contract in `docs/cmf-extensions.md`: inside a present + /// slot, `StringSet` is present-empty, optional scalars are omitted, + /// non-option scalars are written, flattened member bools are absent. + #[test] + fn present_slots_follow_the_absent_value_contract() { + let mut ext = Extensions::default(); + ext.security = Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension::default()), + client: Some(ClientExtension { + client_id: "app".into(), + ..Default::default() + }), + caller_workload: Some(WorkloadIdentity::default()), + this_workload: Some(WorkloadIdentity::default()), + ..Default::default() + })); + ext.delegation = Some(Arc::new(DelegationExtension::default())); + ext.agent = Some(Arc::new(AgentExtension { + conversation: Some(ConversationContext::default()), + ..Default::default() + })); + ext.meta = Some(Arc::new(MetaExtension::default())); + ext.request = Some(Arc::new(RequestExtension::default())); + ext.http = Some(Arc::new(HttpExtension::default())); + ext.llm = Some(Arc::new(LLMExtension::default())); + ext.mcp = Some(Arc::new(MCPExtension::default())); + ext.completion = Some(Arc::new(CompletionExtension::default())); + ext.provenance = Some(Arc::new(ProvenanceExtension::default())); + ext.framework = Some(Arc::new(FrameworkExtension::default())); + ext.custom = Some(Arc::new(HashMap::new())); + + let bag = bag_of(ext); + + // StringSet: present and empty. + for key in [ + "subject.roles", + "subject.permissions", + "subject.teams", + "client.roles", + "client.permissions", + "client.authorized_scopes", + "client.authorized_audiences", + "client.teams", + "caller_workload.selectors", + "this_workload.selectors", + "security.labels", + "agent.conversation.topics", + "meta.tags", + "llm.capabilities", + ] { + assert_eq!( + bag.get_string_set(key), + Some(&empty()), + "{key} must be present-empty, not omitted" + ); + } + + // Optional strings / ints / derived bools: omitted. + for key in [ + "subject.id", + "subject.type", + "authenticated", + "client.client_name", + "auth_method", + "security.classification", + "delegation.origin_subject_id", + "agent.session_id", + "agent.turn", + "meta.entity_type", + "request.environment", + "http.method", + "http.status", + "llm.model_id", + "mcp.tool.name", + "completion.latency_ms", + "provenance.source", + "framework.framework", + ] { + assert!( + !bag.contains(key), + "{key} is optional and must be omitted when unset" + ); + } + + // Flattened member bools: presence-only. + assert_eq!(bag.get_bool("role.hr"), None); + assert_eq!(bag.get_bool("perm.read"), None); + assert_eq!(bag.get_bool("team.eng"), None); + assert_eq!(bag.get_bool("client.role.partner"), None); + + // Non-option scalars on a present slot: written, including zero/false. + assert_eq!(bag.get_int("delegation.depth"), Some(0)); + assert_eq!(bag.get_bool("delegation.delegated"), Some(false)); + assert_eq!(bag.get_bool("delegated"), Some(false)); + assert_eq!(bag.get_float("delegation.age_seconds"), Some(0.0)); + assert_eq!(bag.get_string("client.client_id"), Some("app")); + assert!(bag.get_string("client.trust_level").is_some()); + + // Empty claims / custom / framework metadata: no parent object key. + assert!(!bag.contains("subject.claims")); + assert!(!bag.contains("claim")); + assert!(!bag.contains("custom")); + assert!(!bag.contains("framework.metadata")); + } + + #[test] + fn original_set_and_flattened_bools_stay_paired() { + let mut ext = Extensions::default(); + ext.security = Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + roles: HashSet::from(["hr".to_owned(), "reader".to_owned()]), + ..Default::default() + }), + ..Default::default() + })); + let bag = bag_of(ext); + assert!(bag.set_contains("subject.roles", "hr")); + assert!(bag.set_contains("subject.roles", "reader")); + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_bool("role.reader"), Some(true)); + assert_eq!(bag.get_bool("role.admin"), None); + assert!( + !bag.set_contains("subject.roles", "admin"), + "a name missing from the set must not appear as a flattened true" + ); + } } diff --git a/crates/ppe-apl-cmf/src/lib.rs b/crates/ppe-apl-cmf/src/lib.rs index d93845d..4510ba8 100644 --- a/crates/ppe-apl-cmf/src/lib.rs +++ b/crates/ppe-apl-cmf/src/lib.rs @@ -45,6 +45,9 @@ //! Each bridge is a pure function that reads one typed source and writes flat //! keys into a borrowed bag: no async, no I/O. This crate defines which keys a //! policy author may reference, so adding one here widens the language. +//! +//! The absent-value contract, the original-vs-flattened relationship, and the +//! per-slot catalog are in `docs/cmf-extensions.md`. /// Bridges agent session and lineage into `agent.*` keys. pub mod agent; diff --git a/crates/ppe-pdp-diff/Cargo.toml b/crates/ppe-pdp-diff/Cargo.toml index adea063..c5d3d1c 100644 --- a/crates/ppe-pdp-diff/Cargo.toml +++ b/crates/ppe-pdp-diff/Cargo.toml @@ -32,6 +32,8 @@ serde_yaml = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +praxis-policy-apl-cmf = { workspace = true } +praxis-policy-core = { workspace = true } [lints] workspace = true diff --git a/crates/ppe-pdp-diff/README.md b/crates/ppe-pdp-diff/README.md index d33bb3c..db90c91 100644 --- a/crates/ppe-pdp-diff/README.md +++ b/crates/ppe-pdp-diff/README.md @@ -28,8 +28,16 @@ Negative subset cases must all **deny**. Cause kinds may still differ: Cedar no-match is `DefaultDeny`; CEL/OPA `false` is `PolicyFalse`. That triple is named on the case (`AgreeDeny`), not hidden. -Subset policies use same-type literals (int compared to int). They do not -probe missing keys. +Present-empty `StringSet` (`empty-set`, `bridge-empty-teams`, +`bridge-empty-roles`) is in the subset: membership is false everywhere, +including APL `require(subject.roles contains "hr")`. Cedar rebuilds +`principal.roles` from flattened `role.*` trues; CEL and OPA read the +original `subject.roles` set. The bridge writes both from the same +`HashSet`, so they agree when empty. + +Unguarded probes of **omitted scalars** and of a flattened bool whose +namespace was never written are not in the subset. See +[`docs/cmf-extensions.md`](../../docs/cmf-extensions.md). ## Out of subset (allowlist) @@ -38,9 +46,10 @@ probe missing keys. | `floats-claim` | `AttributeValue::Float` on `claim.*` | Cedar has no float type; claims are stringified. CEL/OPA compare numerically. | | `floats-whole` | `Float(2.0)` on a claim | CEL/OPA coerce whole floats to int. Cedar still has a string, so `== 2` does not match. | | `floats-resource` | float in Cedar `resource.attributes` | Cedar rejects at entity build (`PdpError::Dispatch`). CEL/OPA accept the bag value. | -| `empty-set` | empty `StringSet` on `subject.teams` | Present-empty: Cedar empty set, CEL/OPA empty list, `in`/`contains` is false. | -| `missing-collection` | no `role.*` keys | Cedar empty set (clean false). Unguarded CEL `role.hr` is an eval error. OPA without `default` is undefined. | +| `missing-collection` | no `role.*` keys, unguarded CEL `role.hr` | Cedar empty set (clean false). Unguarded CEL is an eval error. OPA without `default` is undefined. | | `missing-subject-id` | no `subject.id` | Cedar cannot build a principal. CEL eval error. OPA undefined. | +| `missing-claim-string` | omitted `claim.tenant` | Optional strings are omitted. Unguarded equality is a CEL/Cedar eval error and an undefined OPA query. | +| `missing-claim-int` | omitted `claim.depth` | Same as a missing string; emitting `0` would pass a `<= 2` gate. | Each allowlist row in `src/allowlist.rs` carries a `reason`. An unused id or an empty reason fails the meta tests. diff --git a/crates/ppe-pdp-diff/src/allowlist.rs b/crates/ppe-pdp-diff/src/allowlist.rs index 20a8f35..bf3fa15 100644 --- a/crates/ppe-pdp-diff/src/allowlist.rs +++ b/crates/ppe-pdp-diff/src/allowlist.rs @@ -17,9 +17,11 @@ pub(crate) struct AllowlistEntry { pub(crate) opa: Outcome, } -/// Seed entries from issue #25 (floats, empty collections) plus the -/// closely related splits the seed implies (whole floats, resource -/// floats, missing principal). +/// Seed entries from issue #25 (floats, missing collections) plus the +/// omitted-scalar splits the CMF absent-value contract in +/// `docs/cmf-extensions.md` names (missing claim string/int, missing +/// principal). Present-empty `StringSet` is not a split: it lives in the +/// subset as `AgreeDeny`. pub(crate) fn allowlist() -> Vec { vec![ AllowlistEntry { @@ -54,25 +56,18 @@ pub(crate) fn allowlist() -> Vec { cel: Outcome::allow(), opa: Outcome::allow(), }, - AllowlistEntry { - id: "empty-set", - reason: "An empty `StringSet` is present. Cedar always materializes \ - `principal.teams` (possibly empty) because strict mode \ - errors on a missing attribute; `contains` is false. CEL \ - and OPA see an empty list/array and `in` is false. This \ - is not the missing-key case.", - cedar: Outcome::deny(CauseKind::DefaultDeny), - cel: Outcome::deny(CauseKind::PolicyFalse), - opa: Outcome::deny(CauseKind::PolicyFalse), - }, AllowlistEntry { id: "missing-collection", - reason: "No `role.*` keys. Cedar still has an empty `roles` set, so \ - `contains` is a clean false (default deny). Unguarded CEL \ - `role.hr` is an eval error (the `role` namespace is \ - absent). OPA with no `default` leaves `allow` undefined — \ - a clean deny. Same absent-ish state, three mechanisms; \ - only Cedar's empty set is guaranteed by the bridge.", + reason: "No `role.*` keys and no `subject.roles` set. Cedar still \ + has an empty `roles` set, so `contains` is a clean false \ + (default deny). Unguarded CEL `role.hr` is an eval error \ + (the `role` namespace is absent). OPA with no `default` \ + leaves `allow` undefined — a clean deny. The bridge \ + contract in `docs/cmf-extensions.md` is: write the \ + original set present-empty and keep flattened bools \ + presence-only. Authors who need agreement use \ + `subject.roles` (see `empty-set` / `bridge-empty-teams`) \ + or guard CEL with `has(role.hr)`.", cedar: Outcome::deny(CauseKind::DefaultDeny), cel: Outcome::deny(CauseKind::EvalError), opa: Outcome::deny(CauseKind::DefaultDeny), @@ -89,6 +84,30 @@ pub(crate) fn allowlist() -> Vec { cel: Outcome::deny(CauseKind::EvalError), opa: Outcome::deny(CauseKind::DefaultDeny), }, + AllowlistEntry { + id: "missing-claim-string", + reason: "Optional strings are omitted, not defaulted. Unguarded \ + `claim.tenant == \"acme\"` is a CEL eval error (no \ + `claim` namespace). Cedar injects an empty claims \ + record, then a missing field is an evaluation error. \ + OPA without `default` leaves the query undefined. APL \ + would treat the comparison as false; that is why the \ + native evaluator is not asserted here.", + cedar: Outcome::deny(CauseKind::EvalError), + cel: Outcome::deny(CauseKind::EvalError), + opa: Outcome::deny(CauseKind::DefaultDeny), + }, + AllowlistEntry { + id: "missing-claim-int", + reason: "Same omission as a missing string, for `Int`. \ + `claim.depth <= 2` against an absent key is a CEL eval \ + error, a Cedar evaluation error on the empty claims \ + record, and an undefined OPA query. Emitting `0` would \ + make a missing depth pass a `<= 2` gate.", + cedar: Outcome::deny(CauseKind::EvalError), + cel: Outcome::deny(CauseKind::EvalError), + opa: Outcome::deny(CauseKind::DefaultDeny), + }, ] } diff --git a/crates/ppe-pdp-diff/src/cases.rs b/crates/ppe-pdp-diff/src/cases.rs index 4ffdcec..988a389 100644 --- a/crates/ppe-pdp-diff/src/cases.rs +++ b/crates/ppe-pdp-diff/src/cases.rs @@ -29,7 +29,8 @@ pub(crate) enum Expect { Diverge(&'static str), } -/// One bag, one intent, three dialect texts. +/// One bag, one intent, three dialect texts, and optionally an APL rule +/// with the same polarity so the native evaluator is checked too. pub(crate) struct Case { pub(crate) name: &'static str, pub(crate) bag: AttributeBag, @@ -38,6 +39,9 @@ pub(crate) struct Case { pub(crate) opa_module: String, pub(crate) opa_query: String, pub(crate) cedar_resource_attrs: Option, + /// APL deny-rule whose verdict must match the agreed PDP verdict. + /// `None` on allowlist splits and on cases that have no APL spelling. + pub(crate) apl_rule: Option<&'static str>, pub(crate) expect: Expect, } @@ -58,8 +62,12 @@ pub(crate) fn catalog() -> Vec { float_whole(), float_resource(), empty_set(), + bridge_empty_teams(), missing_collection(), + bridge_empty_roles(), missing_subject_id(), + missing_claim_string(), + missing_claim_int(), ] } @@ -106,10 +114,16 @@ fn case( opa_module: opa_allow(opa_rule, opa_default), opa_query: OPA_QUERY.to_owned(), cedar_resource_attrs: None, + apl_rule: None, expect, } } +fn with_apl(mut case: Case, rule: &'static str) -> Case { + case.apl_rule = Some(rule); + case +} + fn string_id_allow() -> Case { case( "string-id-allow", @@ -293,21 +307,91 @@ fn float_resource() -> Case { opa_module: opa_allow("allow if input.resource.score > 1.0", true), opa_query: OPA_QUERY.to_owned(), cedar_resource_attrs: Some(attrs), + apl_rule: None, expect: Expect::Diverge("floats-resource"), } } +fn missing_subject_id() -> Case { + Case { + name: "missing-subject-id", + bag: AttributeBag::new(), + cedar_policy: cedar_permit(), + cel_expr: r#"subject.id == "alice""#.to_owned(), + opa_module: opa_allow(r#"allow if input.subject.id == "alice""#, false), + opa_query: OPA_QUERY.to_owned(), + cedar_resource_attrs: None, + apl_rule: None, + expect: Expect::Diverge("missing-subject-id"), + } +} + +fn alice_via_bridge() -> AttributeBag { + use std::sync::Arc; + + use praxis_policy_apl_cmf::extract_extensions; + use praxis_policy_core::extensions::{ + Extensions, SecurityExtension, SubjectExtension, SubjectType, + }; + + let ext = Extensions { + security: Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_extensions(&ext, &mut bag); + bag +} + +fn agree_deny() -> Expect { + Expect::AgreeDeny { + cedar: CauseKind::DefaultDeny, + cel: CauseKind::PolicyFalse, + opa: CauseKind::PolicyFalse, + } +} + fn empty_set() -> Case { + // Hand-built present-empty set: the bag shape the contract names, without + // going through the bridge. Cause kinds match the other negative subset + // cases; this is not a dialect split. let mut bag = alice(); bag.set("subject.teams", HashSet::::new()); - case( - "empty-set", - bag, - r#"principal.teams.contains("eng")"#, - r#""eng" in subject.teams"#, - r#"allow if "eng" in input.subject.teams"#, - true, - Expect::Diverge("empty-set"), + with_apl( + case( + "empty-set", + bag, + r#"principal.teams.contains("eng")"#, + r#""eng" in subject.teams"#, + r#"allow if "eng" in input.subject.teams"#, + true, + agree_deny(), + ), + r#"require(subject.teams contains "eng")"#, + ) +} + +fn bridge_empty_teams() -> Case { + // Same intent as empty-set, bag produced by extract_extensions so a + // missing member is tested against the contract the PDPs actually see. + with_apl( + case( + "bridge-empty-teams", + alice_via_bridge(), + r#"principal.teams.contains("eng")"#, + r#""eng" in subject.teams"#, + r#"allow if "eng" in input.subject.teams"#, + true, + agree_deny(), + ), + r#"require(subject.teams contains "eng")"#, ) } @@ -323,15 +407,46 @@ fn missing_collection() -> Case { ) } -fn missing_subject_id() -> Case { - Case { - name: "missing-subject-id", - bag: AttributeBag::new(), - cedar_policy: cedar_permit(), - cel_expr: r#"subject.id == "alice""#.to_owned(), - opa_module: opa_allow(r#"allow if input.subject.id == "alice""#, false), - opa_query: OPA_QUERY.to_owned(), - cedar_resource_attrs: None, - expect: Expect::Diverge("missing-subject-id"), - } +fn bridge_empty_roles() -> Case { + // Roles are the split mapping: Cedar rebuilds `principal.roles` from + // flattened `role.*=true`, while CEL / OPA / APL read `subject.roles`. + // The bridge writes both from the same set, so an empty set Denies on + // every engine. `has(role.hr)` is not this case — without a `role` + // namespace CEL still errors (see `missing-collection`). + with_apl( + case( + "bridge-empty-roles", + alice_via_bridge(), + r#"principal.roles.contains("hr")"#, + r#""hr" in subject.roles"#, + r#"allow if "hr" in input.subject.roles"#, + true, + agree_deny(), + ), + r#"require(subject.roles contains "hr")"#, + ) +} + +fn missing_claim_string() -> Case { + case( + "missing-claim-string", + alice(), + r#"principal.claims.tenant == "acme""#, + r#"claim.tenant == "acme""#, + r#"allow if input.claim.tenant == "acme""#, + false, + Expect::Diverge("missing-claim-string"), + ) +} + +fn missing_claim_int() -> Case { + case( + "missing-claim-int", + alice(), + "principal.claims.depth <= 2", + "claim.depth <= 2", + "allow if input.claim.depth <= 2", + false, + Expect::Diverge("missing-claim-int"), + ) } diff --git a/crates/ppe-pdp-diff/src/lib.rs b/crates/ppe-pdp-diff/src/lib.rs index 0adda10..064f86a 100644 --- a/crates/ppe-pdp-diff/src/lib.rs +++ b/crates/ppe-pdp-diff/src/lib.rs @@ -9,8 +9,9 @@ //! disagreement fails the build. //! //! The semantic subset and the known-divergence allowlist are documented in -//! this crate's `README.md`. That document is the contract; the catalog and -//! allowlist here are the executable form. +//! this crate's `README.md`. The CMF absent-value contract those cases +//! check is `docs/cmf-extensions.md`. The catalog and allowlist here are +//! the executable form. /// Factory `kind:` strings this harness drives. /// @@ -46,6 +47,9 @@ mod tests { use praxis_policy_apl_core::attributes::AttributeValue; + use praxis_policy_apl_core::evaluator::{Decision, evaluate_rules}; + use praxis_policy_apl_core::parser::parse_rule; + use super::HARNESS_PDP_KINDS; use super::allowlist::{allowlist, allowlist_by_id}; use super::cases::{Case, Expect, catalog}; @@ -141,6 +145,20 @@ mod tests { } } + #[test] + fn absent_value_agreement_cases_check_apl() { + for name in ["empty-set", "bridge-empty-teams", "bridge-empty-roles"] { + let case = catalog() + .into_iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("catalog must include '{name}'")); + assert!( + case.apl_rule.is_some(), + "{name} must run an APL rule so all four decision points are checked" + ); + } + } + #[test] fn harness_kinds_match_drivers() { let mut from_const: Vec<&str> = HARNESS_PDP_KINDS.to_vec(); @@ -174,6 +192,7 @@ mod tests { case.name ); } + assert_apl(case, true); }, Expect::AgreeDeny { cedar, cel, opa } => { assert_eq!( @@ -210,6 +229,7 @@ mod tests { case.name ); } + assert_apl(case, false); }, Expect::Diverge(id) => { let entry = allowlist_by_id(id) @@ -244,4 +264,21 @@ mod tests { ) }) } + + fn assert_apl(case: &Case, want_allow: bool) { + let Some(src) = case.apl_rule else { + return; + }; + let rule = parse_rule(src, "diff") + .unwrap_or_else(|e| panic!("case '{}': APL rule `{src}` must parse: {e}", case.name)); + match evaluate_rules(&[rule], &case.bag) { + Decision::Allow if want_allow => {}, + Decision::Deny { .. } if !want_allow => {}, + other => panic!( + "case '{}': APL must {}; got {other:?}", + case.name, + if want_allow { "Allow" } else { "Deny" } + ), + } + } } diff --git a/docs/cmf-extensions.md b/docs/cmf-extensions.md new file mode 100644 index 0000000..214ebcc --- /dev/null +++ b/docs/cmf-extensions.md @@ -0,0 +1,351 @@ +# CMF extensions and the attribute bag + +A policy is written against a flat `AttributeBag`. The bag is filled by +`praxis-policy-apl-cmf`: each present slot on `Extensions` is walked into dotted +keys. Plugins that received the typed slot still see the original struct. This +document is the contract for what the bridge emits, how original collections +relate to flattened booleans, and which keys exist. + +The twelve slots dispatched by `extract_extensions` are listed below. +`raw_credentials` and `candidate_constraint` are not among them: credentials +never enter the bag, and a routing constraint is not a policy attribute. + +## Contents + +- [Absent values](#absent-values) +- [Original collections and flattened booleans](#original-collections-and-flattened-booleans) +- [`subject.claims`](#subjectclaims) +- [What each decision point does with a missing key](#what-each-decision-point-does-with-a-missing-key) +- [The twelve slots](#the-twelve-slots) +- [Payloads that are not slots](#payloads-that-are-not-slots) + +--- + +## Absent values + +The rule is per **attribute type**, and it only applies inside a **present +slot**. An absent slot writes nothing for its namespace. CEL then reports an +undeclared reference for that namespace; synthesizing empty namespaces for +missing slots is out of scope. + +| Type | When the field is empty or `None` | Why | +|---|---|---| +| `StringSet` | **Present and empty.** Membership is false. | CEL treats a missing key as an evaluation error. `!("banned" in subject.roles)` would deny every subject with no roles — a routine state, including a plugin that lacks `read_roles` and is handed an empty set. | +| `Bool` as a real field (`delegation.delegated`) | **Present**, including `false`. | The field is not optional on the struct. | +| `Bool` as a flattened member (`role.hr`) | **Omitted.** Presence means true. | Emitting `false` for every name that is not a member is impossible. APL reads a missing flattened bool as false; CEL needs `has(role.hr) && role.hr`. | +| `Bool` derived (`authenticated`) | **Omitted** unless `subject.id` is set. | Absence is "not authenticated". Emitting `false` would collapse that with an explicit unauthenticated marker the model does not have. | +| `String` | **Omitted** when `Option::None`. A non-option string (`client.client_id`) is always written, even if empty. | Empty string and missing are different questions (`exists(subject.id)` vs `subject.id == ""`). | +| `Int` | **Omitted** when `Option::None` (`http.status`, `agent.turn`, `completion.latency_ms`). A non-option int (`delegation.depth`) is always written, including `0`. | Emitting `0` for an unset HTTP status would make `http.status >= 500` and `http.status == 0` both lie. | +| `Float` | Same as `Int`. `delegation.age_seconds` is non-option and always written, including `0.0`. | Same reason: a missing telemetry field is not zero. | +| JSON object / claims map | **No parent key.** Each scalar (or scalar-array) child is written under a dotted path. `{}`, `null`, and an array holding a nested container set nothing. | The bag has no map type. See [`subject.claims`](#subjectclaims). | + +`ppe-pdp-diff` is the executable form of this table for the keys Cedar can +see. Empty `subject.teams` and a subject with no roles (no `role.*` keys, +empty `subject.roles`) must Deny on APL, CEL, cedar-direct, and OPA when the +policy is a membership or flattened-bool gate. Unguarded CEL against an +**omitted scalar** remains an evaluation error and lives on the allowlist. + +--- + +## Original collections and flattened booleans + +[Pull request #7](https://github.com/praxis-proxy/policy/pull/7) added the +original CMF collections as bag keys alongside the flattened booleans that +were already there. + +| Original (the set) | Flattened (presence-only) | Write | +|---|---|---| +| `subject.roles` | `role. = true` | Both, from the same `HashSet`. | +| `subject.permissions` | `perm. = true` | Same. | +| `subject.teams` | `team. = true` | Same. | +| `client.roles` | `client.role. = true` | Same. | +| `client.permissions` | `client.perm. = true` | Same. | + +**Authors should use the original set** for membership (`subject.roles contains +"hr"` in APL, `"hr" in subject.roles` in CEL, `"hr" in input.subject.roles` in +OPA). That key is present whenever the subject (or client) sub-record is, so +the four decision points agree on empty. + +Flattened booleans are an APL convenience: `require(role.hr)` is false when the +key is missing. They are not a second source of truth. The bridge always +derives them from the set, so as emitted they cannot disagree. A later +`AttributeBag::set` that writes one and not the other **last write wins** on +that key; the other key is left as it was. Do not mix a hand-built bag with +the bridge if you need them to stay paired. + +Cedar does not read the bag the way CEL and OPA do. `principal.roles` and +`principal.permissions` are rebuilt from flattened `role.*` / `perm.*` trues. +`principal.teams` is read from the original `subject.teams` set. Cedar does +not surface `client.*`, `http.*`, or the other slots as principal attributes. +A Cedar policy that needs those values does not get them from this mapping. + +--- + +## `subject.claims` + +There is no `subject.claims` bag key, and there will not be one until the bag +gains a map type. + +`AttributeValue` is `Bool`, `Int`, `Float`, `String`, or `StringSet`. A JWT +claim object is none of those. The bridge walks each claim through the same +JSON flattener as `custom.*` and `args.*`: + +- a scalar lands at `claim.` with its type kept +- a scalar array, empty included, lands as a `StringSet` (numbers and bools + rendered as strings) +- `{}`, `null`, and an array holding a nested container set no key +- a nested object sets only the children (`claim.realm_access.roles`), never + the parent (`claim.realm_access`) + +Client claims are the same shape under `client.claim.`. + +That is enough for every predicate the language can ask: `claim.tenant == +"acme"`, `claim.realm_access.roles contains "admin"`. What it cannot do is +treat the whole map as one value (`exists(subject.claims)` meaning "any +claim"). Cedar still injects an empty `principal.claims` record so a probe of +the record itself is not a missing-attribute error; individual missing claim +names inside it follow Cedar's own rules. + +To put a dict in the bag would take a sixth `AttributeValue` variant, APL +lookup into it, CEL map construction (already nested from dotted keys, so +partly redundant), a Cedar record that is not string-keyed leftovers, and an +OPA object. The flattened keys would still be required for the predicates that +exist today. Until that type exists, `claim.*` / `client.claim.*` are the +policy surface, and `SubjectExtension.claims` remains the typed form plugins +read. + +--- + +## What each decision point does with a missing key + +| Engine | Missing key | Empty `StringSet` | +|---|---|---| +| APL | false for presence, equality, membership, and order; `!=` is true (an absent key is not equal to a value) | `contains` / `in` is false | +| CEL | evaluation error; default `OnError::Deny` turns it into a denial that reports a key error, not a policy false | `in` is false | +| cedar-direct | empty `roles` / `permissions` / `teams` / `claims` on the principal so those names exist; no `subject.id` is a dispatch error | `contains` is false | +| OPA | undefined; without `default allow := false` the query is a default deny | `in` is false | + +A policy written against a **present-empty set** therefore agrees — including +when Cedar reads flattened `role.*` and CEL reads `subject.roles`, because the +bridge filled both from the same set. A policy written against an **omitted +scalar**, or against a flattened bool whose namespace was never written +(`has(role.hr)` with no `role.*` keys), agrees only if CEL is rewritten onto +the original set. Unguarded CEL is the `missing-collection` / +`missing-subject-id` class of split. + +--- + +## The twelve slots + +Keys listed **always** are written whenever the slot (and, where noted, the +sub-record) is present. The rest are omitted when the field is `None` or the +map has no entry. + +### 1. `security` — `SecurityExtension` + +**Subject** (`sec.subject` present): + +| Key | Type | When | +|---|---|---| +| `subject.id` | String | `id` is `Some` | +| `subject.type` | String (`user` / `agent` / `service` / `system`) | `subject_type` is `Some` | +| `subject.roles` | StringSet | always | +| `role.` | Bool (`true`) | each member of `roles` | +| `subject.permissions` | StringSet | always | +| `perm.` | Bool (`true`) | each member of `permissions` | +| `subject.teams` | StringSet | always | +| `team.` | Bool (`true`) | each member of `teams` | +| `claim.` | flattened JSON | each claim; see [`subject.claims`](#subjectclaims) | +| `authenticated` | Bool (`true`) | `id` is `Some` | + +**Client** (`sec.client` present): + +| Key | Type | When | +|---|---|---| +| `client.client_id` | String | always | +| `client.client_name` | String | `Some` | +| `client.trust_level` | String | always (`first_party` / `third_party` / `internal` / custom / `unknown`) | +| `client.roles` | StringSet | always | +| `client.role.` | Bool (`true`) | each member | +| `client.permissions` | StringSet | always | +| `client.perm.` | Bool (`true`) | each member | +| `client.authorized_scopes` | StringSet | always | +| `client.authorized_audiences` | StringSet | always | +| `client.teams` | StringSet | always | +| `client.claim.` | flattened JSON | each claim | + +**Workload** (`caller_workload` / `this_workload`; same shape, two namespaces). +These are not `agent.*`. `agent.*` is session context. + +| Key | Type | When | +|---|---|---| +| `.spiffe_id` | String | `Some` | +| `.trust_domain` | String | `Some` | +| `.attestor` | String | `Some` | +| `.selectors` | StringSet | always | +| `.client_id` | String | `Some` | + +`attested_at` is not in the bag: APL has no datetime type. + +**Other**, written whenever the security slot itself is present: + +| Key | Type | When | +|---|---|---| +| `auth_method` | String | `Some` | +| `security.labels` | StringSet | always | +| `security.classification` | String | `Some` | + +### 2. `delegation` — `DelegationExtension` + +| Key | Type | When | +|---|---|---| +| `delegation.depth` | Int | always (0 if none) | +| `delegation.delegated` | Bool | always | +| `delegated` | Bool | always (alias of the previous) | +| `delegation.origin_subject_id` | String | `Some` | +| `delegation.actor_subject_id` | String | `Some` | +| `delegation.age_seconds` | Float | always | + +Per-hop scopes, audience, and strategy stay on the typed chain. + +### 3. `agent` — `AgentExtension` + +| Key | Type | When | +|---|---|---| +| `agent.input` | String | `Some` | +| `agent.session_id` | String | `Some` | +| `agent.conversation_id` | String | `Some` | +| `agent.turn` | Int | `Some` | +| `agent.agent_id` | String | `Some` | +| `agent.parent_agent_id` | String | `Some` | +| `agent.conversation.summary` | String | conversation present and summary `Some` | +| `agent.conversation.topics` | StringSet | conversation present (always then) | + +`conversation.history` is not flattened. + +### 4. `meta` — `MetaExtension` + +| Key | Type | When | +|---|---|---| +| `meta.entity_type` | String | `Some` | +| `meta.entity_name` | String | `Some` | +| `meta.tags` | StringSet | always | +| `meta.scope` | String | `Some` | +| `meta.properties.` | String | each map entry | + +### 5. `request` — `RequestExtension` + +| Key | Type | When | +|---|---|---| +| `request.environment` | String | `Some` | +| `request.request_id` | String | `Some` | +| `request.timestamp` | String | `Some` (ISO 8601 text) | +| `request.trace_id` | String | `Some` | +| `request.span_id` | String | `Some` | + +A default request slot adds nothing. + +### 6. `http` — `HttpExtension` + +| Key | Type | When | +|---|---|---| +| `http.method` | String | `Some` | +| `http.path` | String | `Some` | +| `http.host` | String | `Some` | +| `http.scheme` | String | `Some` | +| `http.status` | Int | `Some` (response half) | +| `http.request_headers.` | String | each header; name lowercased | +| `http.response_headers.` | String | each header; name lowercased | + +### 7. `llm` — `LLMExtension` + +| Key | Type | When | +|---|---|---| +| `llm.model_id` | String | `Some` | +| `llm.provider` | String | `Some` | +| `llm.capabilities` | StringSet | always | + +### 8. `mcp` — `MCPExtension` + +**Tool** present: + +| Key | Type | When | +|---|---|---| +| `mcp.tool.name` | String | always | +| `mcp.tool.title` | String | `Some` | +| `mcp.tool.description` | String | `Some` | +| `mcp.tool.server_id` | String | `Some` | +| `mcp.tool.namespace` | String | `Some` | + +**Resource** present: + +| Key | Type | When | +|---|---|---| +| `mcp.resource.uri` | String | always | +| `mcp.resource.name` | String | `Some` | +| `mcp.resource.description` | String | `Some` | +| `mcp.resource.mime_type` | String | `Some` | +| `mcp.resource.server_id` | String | `Some` | + +**Prompt** present: + +| Key | Type | When | +|---|---|---| +| `mcp.prompt.name` | String | always | +| `mcp.prompt.description` | String | `Some` | +| `mcp.prompt.server_id` | String | `Some` | + +Schemas and annotations are not flattened. + +### 9. `completion` — `CompletionExtension` + +| Key | Type | When | +|---|---|---| +| `completion.stop_reason` | String | `Some` (`end` / `return` / `call` / `max_tokens` / `stop_sequence`) | +| `completion.tokens.input` | Int | tokens present | +| `completion.tokens.output` | Int | tokens present | +| `completion.tokens.total` | Int | tokens present | +| `completion.model` | String | `Some` | +| `completion.raw_format` | String | `Some` | +| `completion.created_at` | String | `Some` | +| `completion.latency_ms` | Int | `Some` | + +### 10. `provenance` — `ProvenanceExtension` + +| Key | Type | When | +|---|---|---| +| `provenance.source` | String | `Some` | +| `provenance.message_id` | String | `Some` | +| `provenance.parent_id` | String | `Some` | + +### 11. `framework` — `FrameworkExtension` + +| Key | Type | When | +|---|---|---| +| `framework.framework` | String | `Some` | +| `framework.framework_version` | String | `Some` | +| `framework.node_id` | String | `Some` | +| `framework.graph_id` | String | `Some` | +| `framework.metadata.` | flattened JSON | each metadata entry | + +### 12. `custom` — `HashMap` + +| Key | Type | When | +|---|---|---| +| `custom.` | flattened JSON | each map entry | + +An empty map adds nothing. + +--- + +## Payloads that are not slots + +These use the same walker and the same absent-value rules, but they are not +`extract_extensions` slots: + +| Source | Prefix | +|---|---| +| Request arguments | `args.*` | +| Upstream result | `result.*` | +| Static `data:` tree | `data.*` | +| Route identifier | `route.key` |