diff --git a/CHANGELOG.md b/CHANGELOG.md index b36e3ca1..7b7dabb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,20 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). payload. Adding a phase or a shipped dialect without a cell fails the build. ([#24](https://github.com/praxis-proxy/policy/issues/24)) +- **`docs/content/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 and an omitted claim scalar Deny on APL, CEL, + cedar-direct, and OPA for presence, equality, membership, and order; APL + `!=` on a missing key Allows while the other engines Deny; APL `not in` + Allows with OPA (`not` of undefined is true) while CEL and cedar-direct + Deny. A flattened bool with no namespace, and a missing `subject.id`, + stay on the allowlist. ([#18](https://github.com/praxis-proxy/policy/issues/18)) + - Added PPE documentation ([#82](https://github.com/praxis-proxy/policy/pull/82)) ### Fixed @@ -42,6 +56,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - **Serial/transform panics keep prior `local_state`.** The executor snapshots context into the task so a contained panic does not remove the plugin's existing map. +- **`read_labels` / `read_workload` bag prefixes.** `capability_namespaces` + advertised nothing for `read_labels` and `workload.*` for `read_workload`, + neither of which the extractors write. It now returns `security.labels` + and `caller_workload.*` / `this_workload.*`. + +### Removed + +- **`BAG_WORKLOAD_PREFIX`.** The unused public `workload.` constant is gone. + Extractors write `caller_workload.*` and `this_workload.*`; nothing + emitted `workload.*`. ### Internal diff --git a/Cargo.lock b/Cargo.lock index 7a36d609..a9850cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2412,6 +2412,7 @@ dependencies = [ "serde_yaml", "thiserror", "tokio", + "tracing", ] [[package]] @@ -2500,7 +2501,9 @@ dependencies = [ name = "praxis-policy-pdp-diff" version = "0.2.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/builtins/pdps/cedar-direct/src/entities.rs b/builtins/pdps/cedar-direct/src/entities.rs index 73103b5f..5eb60ffc 100644 --- a/builtins/pdps/cedar-direct/src/entities.rs +++ b/builtins/pdps/cedar-direct/src/entities.rs @@ -92,6 +92,9 @@ pub fn build_principal( })? .to_owned(); + // Missing type defaults to PascalCase `User`. The CMF bridge writes + // lowercase (`user` / `agent` / `service` / `system`), so a type-scoped + // policy can miss a principal whose type was omitted. let kind = bag.get_string("subject.type").unwrap_or("User"); let entity_type = qualify_type(kind, entity_namespace); diff --git a/builtins/pdps/cedar-direct/src/lib.rs b/builtins/pdps/cedar-direct/src/lib.rs index 6dd257ef..1753fe00 100644 --- a/builtins/pdps/cedar-direct/src/lib.rs +++ b/builtins/pdps/cedar-direct/src/lib.rs @@ -44,8 +44,11 @@ // populated from `SecurityExtension.subject`: // // - `subject.id` → entity id (required; missing → request-time error) -// - `subject.type` → entity type ("User", "Agent", "Service", "System"); -// defaults to "User" when absent +// - `subject.type` → entity type. The CMF bridge writes lowercase +// (`user` / `agent` / `service` / `system`). When +// the key is absent this crate defaults to `User` +// (PascalCase), so a type-scoped policy can miss a +// principal whose type was omitted. // - `role.=true` → principal.roles : Set // - `perm.=true` → principal.permissions : Set // - `claim.=v` → principal.claims. = v diff --git a/builtins/pdps/cedar-direct/src/resolver.rs b/builtins/pdps/cedar-direct/src/resolver.rs index 4ee94659..106f48db 100644 --- a/builtins/pdps/cedar-direct/src/resolver.rs +++ b/builtins/pdps/cedar-direct/src/resolver.rs @@ -287,6 +287,8 @@ fn build_principal_uid( let id = bag .get_string("subject.id") .ok_or_else(|| PdpError::Dispatch("bag missing `subject.id`".to_owned()))?; + // Same default as `entities::build_principal`: PascalCase `User` when + // the key is absent. The CMF bridge writes lowercase (`user`). let kind = bag.get_string("subject.type").unwrap_or("User"); let entity_type = match namespace { Some(ns) if !ns.is_empty() => format!("{ns}::{kind}"), diff --git a/crates/ppe-apl-cmf/src/capability_namespaces.rs b/crates/ppe-apl-cmf/src/capability_namespaces.rs index 288f3d86..febebffd 100644 --- a/crates/ppe-apl-cmf/src/capability_namespaces.rs +++ b/crates/ppe-apl-cmf/src/capability_namespaces.rs @@ -98,13 +98,11 @@ const TABLE: &[CapabilityEntry] = &[ ], }, CapabilityEntry { - // Labels are not extracted into discrete bag keys today — - // they live on `Extensions.security.labels` and plugins - // read them directly. APL's BagBuilder doesn't materialize - // a bag-readable label namespace yet; if it does, add the - // prefix constant + reference here. + // Labels flatten as one present-empty StringSet. The typed + // `Extensions.security.labels` slot remains the plugin-facing + // form; this prefix is what a policy author writes. name: CAP_READ_LABELS, - prefixes: &[], + prefixes: &[BAG_SECURITY_LABELS], }, CapabilityEntry { name: CAP_READ_CLIENT, @@ -113,7 +111,9 @@ const TABLE: &[CapabilityEntry] = &[ CapabilityEntry { name: CAP_READ_WORKLOAD, // Exposes both inbound caller workload AND this-host workload. - prefixes: &[BAG_WORKLOAD_PREFIX, BAG_CALLER_WORKLOAD_PREFIX], + // The extractors write `caller_workload.*` / `this_workload.*`; + // there is no `workload.*` prefix. + prefixes: &[BAG_CALLER_WORKLOAD_PREFIX, BAG_THIS_WORKLOAD_PREFIX], }, CapabilityEntry { // Gates `Extensions.raw_credentials.inbound_tokens` — those @@ -306,8 +306,23 @@ mod tests { // payloads, not bag attributes. assert!(capability_namespaces(CAP_READ_INBOUND_CREDENTIALS).is_empty()); assert!(capability_namespaces(CAP_READ_DELEGATED_TOKENS).is_empty()); - // read_labels too — labels aren't materialized into bag keys. - assert!(capability_namespaces(CAP_READ_LABELS).is_empty()); + } + + #[test] + fn read_labels_exposes_security_labels() { + let prefixes = capability_namespaces(CAP_READ_LABELS); + assert_eq!(prefixes, &[BAG_SECURITY_LABELS]); + } + + #[test] + fn read_workload_exposes_caller_and_this_prefixes() { + let prefixes = capability_namespaces(CAP_READ_WORKLOAD); + assert!(prefixes.contains(&BAG_CALLER_WORKLOAD_PREFIX)); + assert!(prefixes.contains(&BAG_THIS_WORKLOAD_PREFIX)); + assert!( + !prefixes.iter().any(|p| p.starts_with("workload.")), + "extractors write caller_workload.* / this_workload.*, not workload.*" + ); } #[test] diff --git a/crates/ppe-apl-cmf/src/constants.rs b/crates/ppe-apl-cmf/src/constants.rs index 144cae3e..c9aa2e09 100644 --- a/crates/ppe-apl-cmf/src/constants.rs +++ b/crates/ppe-apl-cmf/src/constants.rs @@ -126,10 +126,12 @@ pub const BAG_CLIENT_ROLES: &str = "client.roles"; /// Bag key `client.permissions` — the client's full permission set, /// mirroring the flattened `client.perm.` keys as one `StringSet`. pub const BAG_CLIENT_PERMISSIONS: &str = "client.permissions"; -/// Key prefix for workload identity, as in `workload.`. -pub const BAG_WORKLOAD_PREFIX: &str = "workload."; /// Key prefix for caller workload, as in `caller_workload.`. pub const BAG_CALLER_WORKLOAD_PREFIX: &str = "caller_workload."; +/// Key prefix for this host's workload, as in `this_workload.`. +pub const BAG_THIS_WORKLOAD_PREFIX: &str = "this_workload."; +/// Bag key `security.labels`. +pub const BAG_SECURITY_LABELS: &str = "security.labels"; /// Key prefix for the delegation chain, as in `delegation.`. pub const BAG_DELEGATION_PREFIX: &str = "delegation."; diff --git a/crates/ppe-apl-cmf/src/extensions_bridge.rs b/crates/ppe-apl-cmf/src/extensions_bridge.rs index 702a8597..86494ac1 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/content/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, DataPolicy, + DelegationExtension, FrameworkExtension, HttpExtension, LLMExtension, MCPExtension, + MetaExtension, ObjectSecurityProfile, ProvenanceExtension, RequestExtension, + RetentionPolicy, SecurityExtension, SubjectExtension, WorkloadIdentity, }; - use std::collections::HashSet; + use std::collections::{HashMap, HashSet}; use std::sync::Arc; #[test] @@ -123,4 +130,197 @@ 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/content/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" + ); + } + + #[test] + fn objects_and_data_stay_off_the_bag() { + // `docs/content/cmf-extensions.md`: security.objects / security.data are + // typed-slot only. filter_extensions copies them unrestricted; + // extract_extensions does not flatten them. Distinct from the + // static `data:` payload tree. + let mut objects = HashMap::new(); + objects.insert( + "file".into(), + ObjectSecurityProfile { + managed_by: Some("alice".into()), + permissions: vec!["read".into()], + trust_domain: Some("td".into()), + data_scope: vec!["pii".into()], + }, + ); + let mut data = HashMap::new(); + data.insert( + "ssn".into(), + DataPolicy { + apply_labels: vec!["PII".into()], + allowed_actions: Some(vec!["read".into()]), + denied_actions: vec!["delete".into()], + retention: Some(RetentionPolicy { + max_age_seconds: Some(86_400), + policy: "keep-1d".into(), + delete_after: Some("2030-01-01".into()), + }), + }, + ); + let mut ext = Extensions::default(); + ext.security = Some(Arc::new(SecurityExtension { + objects, + data, + ..Default::default() + })); + let bag = bag_of(ext); + assert!( + bag.contains("security.labels"), + "the security slot itself must still be bridged" + ); + for (key, _) in bag.iter() { + assert!( + !key.contains("objects") + && !key.starts_with("security.data") + && key != "apply_labels" + && key != "allowed_actions" + && key != "denied_actions" + && key != "retention", + "unbridged security.objects / security.data leaked into the bag as {key}" + ); + } + } } diff --git a/crates/ppe-apl-cmf/src/lib.rs b/crates/ppe-apl-cmf/src/lib.rs index d93845dd..ae983b57 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/content/cmf-extensions.md`. /// Bridges agent session and lineage into `agent.*` keys. pub mod agent; diff --git a/crates/ppe-apl-cmf/src/payload.rs b/crates/ppe-apl-cmf/src/payload.rs index 9dcf411b..f6cfb05a 100644 --- a/crates/ppe-apl-cmf/src/payload.rs +++ b/crates/ppe-apl-cmf/src/payload.rs @@ -4,17 +4,17 @@ // JSON args/result payload → AttributeBag. // // Leaf scalars at any nesting depth land in the bag under their dotted -// path, prefixed with `args.` or `result.`. Nested objects recurse; -// scalar arrays flatten into a StringSet, numbers and bools rendered as -// strings (empty array → empty set); arrays holding a nested array or -// object are skipped (no list scalar in the bag). +// path. Nested objects recurse; scalar arrays flatten into a StringSet, +// numbers and bools rendered as strings (empty array → empty set); arrays +// holding a nested array or object are skipped (no list scalar in the bag). // -// Examples: -// args = { "include_ssn": true, -// "user": { "id": "alice", "roles": ["hr", "manager"] } } -// → args.include_ssn : Bool(true) -// args.user.id : String("alice") -// args.user.roles : StringSet({"hr", "manager"}) +// The prefix itself is the key when the JSON root is not an object: +// `"hello"` → `args` : String("hello") +// `["a", "b"]` → `args` : StringSet({"a","b"}) +// `{ "include_ssn": true, "user": { "id": "alice", "roles": ["hr"] } }` +// → `args.include_ssn` : Bool(true) +// `args.user.id` : String("alice") +// `args.user.roles` : StringSet({"hr"}) // // Null values are skipped (consistent with bag's missing-key semantics). @@ -24,14 +24,17 @@ use std::collections::HashSet; use crate::constants::{BAG_ARGS_PREFIX, BAG_RESULT_PREFIX}; -/// Flatten an args object into `args.*` keys. +/// Flatten an args JSON value into bag keys. An object writes +/// `args.` children; a top-level scalar or scalar array writes +/// the bare key `args`. pub fn extract_args(args: &Value, bag: &mut AttributeBag) { // `walk` builds dotted paths itself; strip the trailing `.` from // the canonical prefix to match its signature. walk(args, BAG_ARGS_PREFIX.trim_end_matches('.'), bag); } -/// Flatten a result object into `result.*` keys. +/// Flatten a result JSON value into bag keys. Same shapes as +/// [`extract_args`], under `result` / `result.`. pub fn extract_result(result: &Value, bag: &mut AttributeBag) { walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag); } @@ -127,6 +130,37 @@ mod tests { assert_eq!(bag.get_string("args.name"), Some("alice")); } + #[test] + fn top_level_scalar_payload_uses_the_bare_prefix() { + let mut bag = AttributeBag::new(); + extract_args(&json!("hello"), &mut bag); + assert_eq!(bag.get_string("args"), Some("hello")); + assert_eq!(bag.len(), 1); + + let mut bag = AttributeBag::new(); + extract_args(&json!(true), &mut bag); + assert_eq!(bag.get_bool("args"), Some(true)); + assert_eq!(bag.len(), 1); + + let mut bag = AttributeBag::new(); + extract_result(&json!(42), &mut bag); + assert_eq!(bag.get_int("result"), Some(42)); + assert_eq!(bag.len(), 1); + } + + #[test] + fn top_level_scalar_array_payload_uses_the_bare_prefix() { + let mut bag = AttributeBag::new(); + extract_args(&json!(["a", "b"]), &mut bag); + assert!(bag.set_contains("args", "a")); + assert!(bag.set_contains("args", "b")); + + let mut bag = AttributeBag::new(); + extract_result(&json!([]), &mut bag); + assert!(bag.contains("result")); + assert!(!bag.set_contains("result", "anything")); + } + #[test] fn args_nested_objects_dotted() { let args = json!({ "user": { "id": "alice", "profile": { "tier": "gold" } } }); diff --git a/crates/ppe-apl-cmf/src/security.rs b/crates/ppe-apl-cmf/src/security.rs index 633a80b0..779b3d66 100644 --- a/crates/ppe-apl-cmf/src/security.rs +++ b/crates/ppe-apl-cmf/src/security.rs @@ -75,6 +75,12 @@ // sec.auth_method → auth_method : String // sec.labels → security.labels : StringSet (always) // sec.classification → security.classification : String +// sec.objects, sec.data → not in the bag. Both stay on the +// typed slot (`filter_extensions` +// copies them unrestricted). Plugins +// read ObjectSecurityProfile / +// DataPolicy directly. Distinct from +// the static `data:` payload tree. use praxis_policy_apl_core::AttributeBag; use praxis_policy_core::extensions::{ @@ -211,9 +217,9 @@ pub fn extract_workload(prefix: &str, w: &WorkloadIdentity, bag: &mut AttributeB if let Some(id) = &w.client_id { bag.set(format!("{prefix}.client_id"), id.clone()); } - // `attested_at` intentionally omitted from the bag at v0 — APL - // doesn't carry DateTime as a bag value type, and policies that - // need it can opt into reading the typed extension directly. + // `attested_at` is not in the bag. `request.timestamp` and + // `completion.created_at` are carried as plain strings, so the bag + // does not refuse timestamps; unifying the three is out of scope. let _ = &w.attested_at; } diff --git a/crates/ppe-apl-core/Cargo.toml b/crates/ppe-apl-core/Cargo.toml index 8d497fa0..94cc8cf4 100644 --- a/crates/ppe-apl-core/Cargo.toml +++ b/crates/ppe-apl-core/Cargo.toml @@ -58,6 +58,7 @@ test-util = [] [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +tracing = { workspace = true } # Path-only rather than `workspace = true`, because the workspace entry carries a # version requirement and a self dev-dependency cannot satisfy one: at package # time cargo resolves the requirement against the registry, where this version diff --git a/crates/ppe-apl-core/src/evaluator.rs b/crates/ppe-apl-core/src/evaluator.rs index 3d912f4f..970afebf 100644 --- a/crates/ppe-apl-core/src/evaluator.rs +++ b/crates/ppe-apl-core/src/evaluator.rs @@ -2284,6 +2284,48 @@ mod tests { assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); } + #[test] + fn missing_key_matches_cmf_extensions_table() { + tracing::debug!( + "docs/content/cmf-extensions.md — APL missing-key row: \ + presence/equality/membership/order are false; negated forms are true" + ); + let bag = AttributeBag::new(); + assert!(!eval_pred("authenticated", &bag)); + assert!(!eval_pred(r#"subject.id == "alice""#, &bag)); + assert!(!eval_pred(r#"subject.roles contains "hr""#, &bag)); + assert!(!eval_pred("http.status > 0", &bag)); + assert!( + eval_pred(r#"subject.id != "alice""#, &bag), + "!= on an absent key is true (duality with !(==))" + ); + assert!( + eval_pred("!authenticated", &bag), + "negation of an absent key is true" + ); + assert!( + eval_pred(r#"!(subject.id == "alice")"#, &bag), + "`!(...)` of a missing comparison is true" + ); + assert!( + eval_pred("subject.type not in blocked_types", &bag), + "`not in` on a missing set is true" + ); + let rule = crate::parser::parse_rule("require(authenticated)", "test") + .expect("require(authenticated) parses"); + assert!( + matches!(evaluate_rules(&[rule], &bag), Decision::Deny { .. }), + "require(authenticated) fires on an empty bag" + ); + let denylist = + crate::parser::parse_rule("require(subject.type not in blocked_types)", "test") + .expect("require(not in) parses"); + assert!( + matches!(evaluate_rules(&[denylist], &bag), Decision::Allow), + "require(subject.type not in blocked_types) Allows: missing-key `not in` is true, so require does not fire" + ); + } + #[test] fn missing_key_is_false() { let mut bag = AttributeBag::new(); @@ -2299,7 +2341,7 @@ mod tests { }, &bag )); - // Comparison on missing → false. + // Comparison on missing: equality is false; `!=` is a separate test. assert!(!eval_condition( &Condition::Comparison { key: "missing".into(), diff --git a/crates/ppe-pdp-diff/Cargo.toml b/crates/ppe-pdp-diff/Cargo.toml index e0f7281b..ac78acad 100644 --- a/crates/ppe-pdp-diff/Cargo.toml +++ b/crates/ppe-pdp-diff/Cargo.toml @@ -35,6 +35,8 @@ serde_yaml = { workspace = true } # feature only here keeps it off a normal build of this crate (and of every # workspace crate that depends on it). praxis-policy-apl-core = { workspace = true, features = ["test-util"] } +praxis-policy-apl-cmf = { workspace = true } +praxis-policy-core = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "test-util"] } [lints] diff --git a/crates/ppe-pdp-diff/README.md b/crates/ppe-pdp-diff/README.md index d33bb3ce..c4f876e6 100644 --- a/crates/ppe-pdp-diff/README.md +++ b/crates/ppe-pdp-diff/README.md @@ -28,8 +28,23 @@ 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** whose namespace was never written +(`missing-collection`) and a missing `subject.id` are not in the subset. +Omitted **claim** scalars (`missing-claim-string`, `missing-claim-int`) +Deny on all four engines and are `AgreeDeny`: CEL and Cedar report a key +error rather than a policy false. See +[`docs/content/cmf-extensions.md`](../../docs/content/cmf-extensions.md). +`!=` and `not in` on an omitted key do **not** agree across all four. +APL `!=` Allows while CEL, cedar-direct, and OPA Deny +(`missing-claim-not-eq`). APL `not in` Allows, OPA Allows with it (`not` +of undefined is true), and CEL/cedar-direct Deny (`missing-not-in`). ## Out of subset (allowlist) @@ -38,9 +53,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. `has(role.hr)` is not a guard: the `role` namespace is absent. Use `subject.roles`. | | `missing-subject-id` | no `subject.id` | Cedar cannot build a principal. CEL eval error. OPA undefined. | +| `missing-claim-not-eq` | omitted `claim.tenant`, `!=` | APL `!=` is true so `require` Allows. CEL/Cedar eval error. OPA undefined. | +| `missing-not-in` | omitted denylist set, `not in` | APL Allows. OPA `not (x in y)` on undefined `y` is true (Allow). CEL/Cedar eval error (Deny). | 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 20a8f35b..4ee5c76f 100644 --- a/crates/ppe-pdp-diff/src/allowlist.rs +++ b/crates/ppe-pdp-diff/src/allowlist.rs @@ -15,11 +15,14 @@ pub(crate) struct AllowlistEntry { pub(crate) cedar: Outcome, pub(crate) cel: Outcome, pub(crate) opa: Outcome, + /// When `Some`, the catalog case must carry an `apl_rule` whose verdict + /// matches this flag. `None` for splits that have no APL spelling. + pub(crate) apl_allows: Option, } -/// 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 a +/// missing principal. Present-empty `StringSet` and omitted claim +/// scalars are not splits: they live in the subset as `AgreeDeny`. pub(crate) fn allowlist() -> Vec { vec![ AllowlistEntry { @@ -33,6 +36,7 @@ pub(crate) fn allowlist() -> Vec { cedar: Outcome::deny(CauseKind::EvalError), cel: Outcome::allow(), opa: Outcome::allow(), + apl_allows: None, }, AllowlistEntry { id: "floats-whole", @@ -43,6 +47,7 @@ pub(crate) fn allowlist() -> Vec { cedar: Outcome::deny(CauseKind::DefaultDeny), cel: Outcome::allow(), opa: Outcome::allow(), + apl_allows: None, }, AllowlistEntry { id: "floats-resource", @@ -53,29 +58,26 @@ pub(crate) fn allowlist() -> Vec { cedar: Outcome::dispatch_error(), 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), + apl_allows: None, }, 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/content/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` / \ + `bridge-empty-roles`). `has(role.hr)` is not a CEL guard \ + here: with no `role.*` keys the `role` namespace does not \ + exist, and `has(role.hr)` is itself an eval error.", cedar: Outcome::deny(CauseKind::DefaultDeny), cel: Outcome::deny(CauseKind::EvalError), opa: Outcome::deny(CauseKind::DefaultDeny), + apl_allows: None, }, AllowlistEntry { id: "missing-subject-id", @@ -88,6 +90,38 @@ pub(crate) fn allowlist() -> Vec { cedar: Outcome::dispatch_error(), cel: Outcome::deny(CauseKind::EvalError), opa: Outcome::deny(CauseKind::DefaultDeny), + apl_allows: None, + }, + AllowlistEntry { + id: "missing-claim-not-eq", + reason: "APL `!=` on a missing key is true (duality with `!(==)`), \ + so `require(claim.tenant != \"acme\")` Allows. CEL and \ + Cedar treat the omitted claim as an eval error (Deny). \ + OPA without `default` leaves the query undefined \ + (DefaultDeny). Authors who need the denylist closed \ + write `require(exists(claim.tenant) & claim.tenant != \ + \"acme\")`. See `docs/content/cmf-extensions.md`.", + cedar: Outcome::deny(CauseKind::EvalError), + cel: Outcome::deny(CauseKind::EvalError), + opa: Outcome::deny(CauseKind::DefaultDeny), + apl_allows: Some(true), + }, + AllowlistEntry { + id: "missing-not-in", + reason: "APL `not in` on a missing set is true, so \ + `require(subject.type not in blocked_types)` Allows. CEL \ + `!(subject.type in blocked_types)` is an eval error: \ + `blocked_types` is undeclared. Cedar has no free \ + `blocked_types` bag key; the catalog uses \ + `!(principal.claims.blocked.contains(principal.type))`, \ + which is an eval error on the missing claim set. OPA \ + `not (x in y)` on an undefined `y` is true, so OPA \ + Allows with APL. The split is CEL/Cedar Deny vs \ + APL/OPA Allow.", + cedar: Outcome::deny(CauseKind::EvalError), + cel: Outcome::deny(CauseKind::EvalError), + opa: Outcome::allow(), + apl_allows: Some(true), }, ] } diff --git a/crates/ppe-pdp-diff/src/cases.rs b/crates/ppe-pdp-diff/src/cases.rs index 4ffdcecb..92398ee3 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,14 @@ 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(), + missing_claim_not_eq(), + missing_not_in(), ] } @@ -106,10 +116,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 +309,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 +409,102 @@ 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 { + // All four deny; CEL/Cedar report a key error rather than a policy + // false. That is AgreeDeny, not a dialect split — APL `==` on an + // omitted string is false, so `require` fires. + with_apl( + case( + "missing-claim-string", + alice(), + r#"principal.claims.tenant == "acme""#, + r#"claim.tenant == "acme""#, + r#"allow if input.claim.tenant == "acme""#, + false, + Expect::AgreeDeny { + cedar: CauseKind::EvalError, + cel: CauseKind::EvalError, + opa: CauseKind::DefaultDeny, + }, + ), + r#"require(claim.tenant == "acme")"#, + ) +} + +fn missing_claim_int() -> Case { + // Same verdict agreement as a missing string, for `Int`. Emitting + // `0` would make a missing depth pass a `<= 2` gate. + with_apl( + case( + "missing-claim-int", + alice(), + "principal.claims.depth <= 2", + "claim.depth <= 2", + "allow if input.claim.depth <= 2", + false, + Expect::AgreeDeny { + cedar: CauseKind::EvalError, + cel: CauseKind::EvalError, + opa: CauseKind::DefaultDeny, + }, + ), + "require(claim.depth <= 2)", + ) +} + +fn missing_claim_not_eq() -> Case { + // APL `!=` on a missing key is true, so require does not fire (Allow). + // CEL / Cedar eval-error Deny; OPA default-deny. Documented split. + with_apl( + case( + "missing-claim-not-eq", + alice(), + r#"principal.claims.tenant != "acme""#, + r#"claim.tenant != "acme""#, + r#"allow if input.claim.tenant != "acme""#, + false, + Expect::Diverge("missing-claim-not-eq"), + ), + r#"require(claim.tenant != "acme")"#, + ) +} + +fn missing_not_in() -> Case { + // APL `not in` on a missing set is true, so require Allows. CEL has no + // `blocked_types` namespace (eval error). Cedar has no free + // `blocked_types` key; the nearest missing-set denylist is a missing + // claim set, which is also an eval error. OPA `not (x in y)` on + // undefined `y` is true, so OPA Allows with APL. + with_apl( + case( + "missing-not-in", + alice(), + "!(principal.claims.blocked.contains(principal.type))", + "!(subject.type in blocked_types)", + "allow if not (input.subject.type in input.blocked_types)", + false, + Expect::Diverge("missing-not-in"), + ), + "require(subject.type not in blocked_types)", + ) } diff --git a/crates/ppe-pdp-diff/src/lib.rs b/crates/ppe-pdp-diff/src/lib.rs index 6b3c92e4..cc0d7865 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/content/cmf-extensions.md`. The catalog and allowlist here are +//! the executable form. /// Factory `kind:` strings this harness drives. /// @@ -48,6 +49,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}; @@ -143,6 +147,43 @@ mod tests { } } + #[test] + fn absent_value_agreement_cases_check_apl() { + for name in [ + "empty-set", + "bridge-empty-teams", + "bridge-empty-roles", + "missing-claim-string", + "missing-claim-int", + ] { + 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 allowlisted_apl_splits_check_apl() { + for case in catalog() { + if let Expect::Diverge(id) = case.expect { + let entry = allowlist_by_id(id) + .unwrap_or_else(|| panic!("case '{}': unknown allowlist id '{id}'", case.name)); + if entry.apl_allows.is_some() { + assert!( + case.apl_rule.is_some(), + "case '{}' cites '{id}' with an APL verdict; it needs apl_rule", + case.name + ); + } + } + } + } + #[test] fn harness_kinds_match_drivers() { let mut from_const: Vec<&str> = HARNESS_PDP_KINDS.to_vec(); @@ -176,6 +217,7 @@ mod tests { case.name ); } + assert_apl(case, true); }, Expect::AgreeDeny { cedar, cel, opa } => { assert_eq!( @@ -212,6 +254,7 @@ mod tests { case.name ); } + assert_apl(case, false); }, Expect::Diverge(id) => { let entry = allowlist_by_id(id) @@ -234,6 +277,9 @@ mod tests { "case '{}': opa vs allowlist '{id}'; got {opa_detail}", case.name ); + if let Some(want_allow) = entry.apl_allows { + assert_apl(case, want_allow); + } }, } } @@ -246,4 +292,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/content/cmf-extensions.md b/docs/content/cmf-extensions.md new file mode 100644 index 00000000..456df69f --- /dev/null +++ b/docs/content/cmf-extensions.md @@ -0,0 +1,399 @@ +# 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. Do not guard CEL with `has(role.hr)`: when no `role.*` keys exist the `role` namespace was never written, and `has(role.hr)` is an evaluation error. Use the always-present `subject.roles` set (`"hr" in subject.roles`). | +| `Bool` derived (`authenticated`) | **Omitted** unless `subject.id` is set. | Absence is "not authenticated" in APL (`!authenticated` is true; `require(authenticated)` denies). `has(authenticated)` is a compile error (`has()` rejects a bare name), so that key itself cannot be guarded in CEL. When the `subject` namespace exists — a present subject writes empty `subject.roles` / `permissions` / `teams` even with no id — `has(subject.id)` is a valid substitute. Only a completely absent subject (no `subject.*` keys) leaves CEL with no guard; the deny then reaches the operator as a key error. Contrast `delegation.delegated`, a non-option field that is always written, including `false`. | +| `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 **presence, +equality, membership, and order** probes of an omitted claim scalar also +Deny on all four (CEL and Cedar report a key error rather than a policy +false). That agreement does **not** cover APL `!=` or `not in`: those +evaluate true on a missing key, so `require(claim.tenant != "acme")` +Allows in APL while CEL, cedar-direct, and OPA Deny. `not in` Allows in +APL (and in OPA, where `not` of undefined is true) while CEL and +cedar-direct Deny. A flattened bool whose namespace was never +written, and a missing `subject.id`, remain allowlisted: CEL is an eval +error, Cedar cannot build a principal without an id, and making those +agree needs CEL root seeding and a generated Cedar schema, which is a +follow-up to [#18](https://github.com/praxis-proxy/policy/issues/18). + +--- + +## 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. | + +The set is the primary form. The five flattened collections exist because +`require(role.hr)` predates the original sets; no more are added. Nine +other `StringSet`s are set-only — `client.teams`, +`client.authorized_scopes`, `client.authorized_audiences`, +`caller_workload.selectors`, `this_workload.selectors`, `security.labels`, +`agent.conversation.topics`, `meta.tags`, `llm.capabilities` — so +`require(tag.pii)` is false forever, by design. + +**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. Every negated form is true: `!=`, `!key`, `!(...)`, and `not in`. Negation is spelled `!`; `not` is reserved for the `not in` phrase, so the idiom is `!authenticated`. | `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. A missing `subject.type` defaults to `User` (PascalCase). The bridge writes lowercase (`user` / `agent` / `service` / `system`), so a type-scoped policy (`principal is user` vs `User`) can miss a principal whose type was omitted. | `contains` is false | +| OPA | undefined; without `default allow := false` the query is a default deny. `not` of undefined is true, so a denylist written `not (x in y)` Allows when `y` is missing. | `in` is false | + +`require` inverts its predicate and denies when that inversion is true, so +the APL row above splits `require` on a missing key: + +| Rule (omitted keys) | APL | Other engines | +|---|---|---| +| `require(claim.tenant == "acme")` | Deny | Deny | +| `require(subject.roles contains "hr")` | Deny | Deny | +| `require(claim.tenant != "acme")` | Allow | Deny | +| `require(subject.type not in blocked_types)` | Allow | CEL and cedar-direct Deny. OPA Allows: `not` of an undefined set is true. | + +Authors who need the denylist to stay closed when the key is missing write +`require(exists(claim.tenant) & claim.tenant != "acme")`. + +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 +claim scalar** agrees on the verdict (all Deny) for `==`, order, and +membership, and is an `AgreeDeny` in `ppe-pdp-diff`; the cause still differs. +`!=` and `not in` do **not** agree across all four engines: they are +`missing-claim-not-eq` / `missing-not-in` on the allowlist. A flattened bool +whose namespace was never written (unguarded CEL `role.hr` with no `role.*` +keys), or a missing `subject.id`, 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`. cedar-direct, given no key, still builds a principal typed `User`; bridged values are lowercase. | +| `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. `request.timestamp` and +`completion.created_at` are carried as plain strings, so the bag does not +refuse timestamps; unifying the three is out of scope here. + +**Other**, written whenever the security slot itself is present: + +| Key | Type | When | +|---|---|---| +| `auth_method` | String | `Some` | +| `security.labels` | StringSet | always | +| `security.classification` | String | `Some` | + +`security.objects` and `security.data` are not in the bag. Both live on +`SecurityExtension`, and `filter_extensions` copies them to every plugin +(unrestricted sub-fields). The bridge does not flatten +`ObjectSecurityProfile` or `DataPolicy` (`apply_labels`, `allowed_actions`, +`denied_actions`, `retention`). Plugins that need them read the typed slot. +The static `data:` payload tree (`data.*` keys) is a different source; see +[Payloads that are not slots](#payloads-that-are-not-slots). + +`capability_namespaces` maps `read_*` capabilities to bag prefixes. `read_labels` +unlocks `security.labels`; `read_workload` unlocks `caller_workload.*` and +`this_workload.*`. Nothing writes a `workload.*` prefix. + +### 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 | Keys | +|---|---| +| Request arguments | Object fields: `args.`. A top-level scalar is the key `args` (String / Bool / Int / Float). A top-level scalar array is `args` as a StringSet (empty included). A top-level array of objects or nested arrays sets nothing. `null` sets nothing. | +| Upstream result | Same shapes under `result` / `result.`. | +| Static `data:` tree | Same walker under `data` / `data.`. | +| Route identifier | `route.key` | diff --git a/docs/content/cmf.md b/docs/content/cmf.md index 9596ab8b..0c8c67e7 100644 --- a/docs/content/cmf.md +++ b/docs/content/cmf.md @@ -55,12 +55,15 @@ covers every operation type mapped to that hook. CMF is the "what you evaluate" layer (see [Vision](vision.md)). Identity, security labels, and delegation context ride alongside the message as typed extensions ([Extensions & Capability-Gating](extensions.md)), and APL reads all -of it through one attribute bag. The message gives policy the content; the -extensions give it the context; APL decides. +of it through one attribute bag +([CMF extensions and the attribute bag](cmf-extensions.md)). The message +gives policy the content; the extensions give it the context; APL decides. ## Next - [Extensions and Capability Gating](extensions.md): inspect the typed context carried alongside each message. +- [CMF extensions and the attribute bag](cmf-extensions.md): the keys APL + reads and what a missing key means in each engine. - [Crates](crates.md): locate the CMF and runtime implementations in the workspace. diff --git a/docs/content/extensions.md b/docs/content/extensions.md index 247e93cc..f8f5723e 100644 --- a/docs/content/extensions.md +++ b/docs/content/extensions.md @@ -9,7 +9,9 @@ write each one. Hosts rarely configure extensions directly. Capability gating restricts the state available to plugins that execute APL effects. The namespaces below are -the exact keys an APL predicate or plugin may read. +the exact keys an APL predicate or plugin may read. The per-type absent-value +contract, original vs flattened keys, and the catalog of every key the +bridge emits are in [CMF extensions and the attribute bag](cmf-extensions.md). ## The extensions @@ -37,9 +39,10 @@ capability. A prefix ending in `.` matches any key beneath it (`role.` matches | Raw credentials | inbound tokens and minted delegated tokens | flow through plugin payloads, not the bag | `read_inbound_credentials`, `read_delegated_tokens` | | Candidate constraint | folded backend routing constraint from `restrict` effects | not a bag namespace — read by the host router | written by the policy engine | -The request arguments and response body are also flattened, under `args.*` and -`result.*`, and the route name is available as `route.key`. APL field pipelines -(`args:` / `result:`) operate on those. Operator-maintained static attributes +The request arguments and response body are also flattened. An object +writes `args.` / `result.`; a top-level scalar or scalar +array writes the bare key `args` / `result`. The route name is +`route.key`. APL field pipelines (`args:` / `result:`) operate on those. Operator-maintained static attributes are flattened under `data.*` — these come from config files, not the request, and need no capability (see [Static Attributes](apl/attributes.md)). diff --git a/docs/content/index.md b/docs/content/index.md index 73ff290c..c1dd0665 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -81,6 +81,8 @@ history. the protocol-agnostic envelope policy reasons about - [Extensions and Capability Gating](extensions.md): typed contextual state, and the capabilities that unlock it +- [CMF extensions and the attribute bag](cmf-extensions.md): + the per-type absent-value contract and every key the bridge emits ## Reference