Skip to content
Merged
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

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

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

3 changes: 3 additions & 0 deletions builtins/pdps/cedar-direct/src/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
7 changes: 5 additions & 2 deletions builtins/pdps/cedar-direct/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>=true` → principal.roles : Set<String>
// - `perm.<name>=true` → principal.permissions : Set<String>
// - `claim.<name>=v` → principal.claims.<name> = v
Expand Down
2 changes: 2 additions & 0 deletions builtins/pdps/cedar-direct/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
Expand Down
33 changes: 24 additions & 9 deletions crates/ppe-apl-cmf/src/capability_namespaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 4 additions & 2 deletions crates/ppe-apl-cmf/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` keys as one `StringSet`.
pub const BAG_CLIENT_PERMISSIONS: &str = "client.permissions";
/// Key prefix for workload identity, as in `workload.<name>`.
pub const BAG_WORKLOAD_PREFIX: &str = "workload.";
/// Key prefix for caller workload, as in `caller_workload.<name>`.
pub const BAG_CALLER_WORKLOAD_PREFIX: &str = "caller_workload.";
/// Key prefix for this host's workload, as in `this_workload.<name>`.
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.<name>`.
pub const BAG_DELEGATION_PREFIX: &str = "delegation.";
Expand Down
206 changes: 203 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/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);
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, 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]
Expand Down Expand Up @@ -123,4 +130,197 @@ 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/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.
Comment thread
araujof marked this conversation as resolved.
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}"
);
}
}
}
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/content/cmf-extensions.md`.

/// Bridges agent session and lineage into `agent.*` keys.
pub mod agent;
Expand Down
Loading