Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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))

- **`assertions:` controls the headers PPE writes at trust boundaries.** Available
alongside `authentication:` at global, default, bundle, and route scope, its
`request:` contract maps engine-derived values such as `subject.id` and
Expand Down Expand Up @@ -72,7 +82,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))

Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 148 additions & 3 deletions crates/ppe-apl-cmf/src/extensions_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -123,4 +130,142 @@ mod tests {
extract_extensions(&ext, &mut bag);
assert!(bag.is_empty());
}

fn empty() -> HashSet<String> {
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"
);
}
}
3 changes: 3 additions & 0 deletions crates/ppe-apl-cmf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions crates/ppe-pdp-diff/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 13 additions & 4 deletions crates/ppe-pdp-diff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down
59 changes: 39 additions & 20 deletions crates/ppe-pdp-diff/src/allowlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AllowlistEntry> {
vec![
AllowlistEntry {
Expand Down Expand Up @@ -54,25 +56,18 @@ pub(crate) fn allowlist() -> Vec<AllowlistEntry> {
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),
Expand All @@ -89,6 +84,30 @@ pub(crate) fn allowlist() -> Vec<AllowlistEntry> {
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),
},
]
}

Expand Down
Loading