From 4a3463cc2af066bee602106257d90f719c1029d5 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 5 Aug 2026 22:35:37 -0400 Subject: [PATCH 1/2] docs: make code comments standalone Drop references to plan-document IDs and design docs that aren't part of the codebase. Also fix two stale tier numbers in the session resolver. Signed-off-by: Frederico Araujo --- .../delegator-oauth/tests/oauth_e2e.rs | 2 +- crates/apl-cmf/src/lib.rs | 4 +- crates/apl-cmf/src/payload.rs | 11 +++--- crates/apl-core/src/attribute_source.rs | 3 +- crates/apl-core/src/attributes.rs | 2 +- crates/apl-core/src/constraint.rs | 5 +-- crates/apl-core/src/evaluator.rs | 8 ++-- crates/apl-core/src/parser.rs | 20 +++++----- crates/apl-core/src/plugin_decl.rs | 2 +- crates/apl-core/src/route.rs | 2 +- crates/apl-core/src/rules.rs | 2 +- crates/apl-core/src/step.rs | 8 ++-- crates/apl-cpex/src/attribute_source.rs | 2 +- crates/apl-cpex/src/candidate_constraint.rs | 3 +- crates/apl-cpex/src/route_handler.rs | 8 ++-- crates/apl-cpex/src/session_resolver.rs | 39 ++++++++++--------- crates/apl-cpex/tests/attribute_source_e2e.rs | 6 +-- crates/apl-cpex/tests/restrict_e2e.rs | 3 +- crates/cpex-core/src/config.rs | 2 +- crates/cpex-core/src/extensions/routing.rs | 5 +-- crates/cpex-core/src/manager.rs | 4 +- 21 files changed, 67 insertions(+), 74 deletions(-) diff --git a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs index 21ba4f52..23b9ffe4 100644 --- a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs +++ b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs @@ -683,7 +683,7 @@ async fn workload_subject_authenticates_by_svid_then_exchanges() { leg2.assert_async().await; } -/// A6: a leg-1 rejection must not echo submitted credential material. Even +/// A leg-1 rejection must not echo submitted credential material. Even /// when the IdP hostilely parrots the SVID back in `error_description`, the /// caller-visible violation carries only the OAuth error code — never the /// `client_assertion` bytes. diff --git a/crates/apl-cmf/src/lib.rs b/crates/apl-cmf/src/lib.rs index fe1c24b5..5ba22ad7 100644 --- a/crates/apl-cmf/src/lib.rs +++ b/crates/apl-cmf/src/lib.rs @@ -128,8 +128,8 @@ impl BagBuilder { self } - /// Flatten a static attribute tree into the `data.*` namespace - /// (design §4.2). The tree is shared and startup-loaded, but this + /// Flatten a static attribute tree into the `data.*` namespace. + /// The tree is shared and startup-loaded, but this /// re-walks it and re-inserts every leaf into the bag on **each call** /// (a `format!` per node, a `bag.set` per leaf) — so `data.*` reads are /// **not** free on the request hot path. The route handler invokes this diff --git a/crates/apl-cmf/src/payload.rs b/crates/apl-cmf/src/payload.rs index c28ea2b3..ad2d4f98 100644 --- a/crates/apl-cmf/src/payload.rs +++ b/crates/apl-cmf/src/payload.rs @@ -37,10 +37,10 @@ pub fn extract_result(result: &Value, bag: &mut AttributeBag) { walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag); } -/// Flatten a static attribute tree into `data.*` keys (design §4.2). -/// Same walk as args/result — nested objects recurse, string arrays -/// become `StringSet`s (so `data.tenants.x.allowed_models` supports -/// `contains` and R3b `restrict` references). +/// Flatten a static attribute tree into `data.*` keys. Same walk as +/// args/result — nested objects recurse, string arrays become +/// `StringSet`s (so `data.tenants.x.allowed_models` supports `contains` +/// and interpolated `restrict` references). pub fn extract_data(tree: &apl_core::AttributeTree, bag: &mut AttributeBag) { walk(tree.as_value(), "data", bag); } @@ -176,7 +176,8 @@ mod tests { bag.get_string("data.tenants.acme-eu.data_region"), Some("eu") ); - // String arrays become a StringSet (ready for `contains` / R3b). + // String arrays become a StringSet (ready for `contains` / + // interpolated path lookups). assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "anthropic/*")); assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "vllm/*")); } diff --git a/crates/apl-core/src/attribute_source.rs b/crates/apl-core/src/attribute_source.rs index 548e629c..f7cba2fe 100644 --- a/crates/apl-core/src/attribute_source.rs +++ b/crates/apl-core/src/attribute_source.rs @@ -9,8 +9,7 @@ // that aren't carried by any token or fetched from anywhere: backend-free // policy constants like tenant→region maps, per-agent model allow-lists, // org defaults. Those come from a plain, operator-organized data tree that -// lands in the evaluation bag under `data.*` (see -// docs/apl-restrict-effect-design.md §4). +// lands in the evaluation bag under `data.*`. // // This module is the pure contract: the `AttributeSource` trait (where a // tree comes from) and the `AttributeTree` value (what it is). The default diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs index 222fd3b3..98067a6f 100644 --- a/crates/apl-core/src/attributes.rs +++ b/crates/apl-core/src/attributes.rs @@ -146,7 +146,7 @@ impl AttributeBag { } /// Resolve an attribute path to its concrete flat key, expanding any - /// `[inner]` interpolation groups (design §4.3). Each `[inner]` looks + /// `[inner]` interpolation groups. Each `[inner]` looks /// `inner` up in this bag and substitutes `.` + its scalar value: /// `data.tenants[subject.tenant].data_region` with `subject.tenant = /// "acme-eu"` → `data.tenants.acme-eu.data_region`. The common diff --git a/crates/apl-core/src/constraint.rs b/crates/apl-core/src/constraint.rs index 6c50a8e1..1e1a2036 100644 --- a/crates/apl-core/src/constraint.rs +++ b/crates/apl-core/src/constraint.rs @@ -6,8 +6,7 @@ // Backend candidate-constraint IR for the `restrict` effect. // // `restrict` narrows the set of backends the host's router/load-balancer -// may select from — it never picks a backend (see -// docs/apl-restrict-effect-design.md). It normally does not allow/deny the +// may select from — it never picks a backend. It normally does not allow/deny the // request either; the one exception is fail-closed integrity — an // unresolvable `deny_models` reference denies, since a deny-list cannot fail // open (see `RestrictResolveError`). It is an accumulating @@ -116,7 +115,7 @@ pub enum OnEmpty { } /// A `restrict` string-set field: either a literal set or a `data.*`/bag -/// reference resolved against the request at eval time (design §4.3). The +/// reference resolved against the request at eval time. The /// YAML shape disambiguates — a sequence is a literal, a bare scalar is a /// reference: /// diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs index b72f5ca1..55f58048 100644 --- a/crates/apl-core/src/evaluator.rs +++ b/crates/apl-core/src/evaluator.rs @@ -535,7 +535,7 @@ async fn dispatch_effect( Effect::Restrict { spec } => { // Resolve any `data.*` field references against this request's - // bag (design §4.3). Allow-list references fail closed by + // bag. Allow-list references fail closed by // resolving to the empty set; an unresolvable *deny*-list // reference is an integrity failure that denies the request — // a deny-list we can't resolve must never route unconstrained. @@ -1632,7 +1632,7 @@ mod tests { Expression::Condition(c) } - // ----- R3b: data.* path interpolation ----- + // ----- data.* path interpolation ----- /// Parse a predicate and evaluate it against `bag`. fn eval_pred(src: &str, bag: &AttributeBag) -> bool { @@ -3351,7 +3351,7 @@ mod tests { ); } - // ----- R1: restrict effect accumulation ----- + // ----- restrict effect accumulation ----- fn restrict_regions(regions: &[&str]) -> Effect { use crate::constraint::{RestrictSpec, StringSetSpec}; @@ -3521,7 +3521,7 @@ mod tests { assert_eq!(e.constraints[0].allow_models.as_deref(), Some(&[][..])); } - // ----- R1b: deny_models references fail closed ----- + // ----- deny_models references fail closed ----- fn restrict_deny_models_ref(path: &str) -> Effect { use crate::constraint::{RestrictSpec, StringSetSpec}; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index 96075fef..378b6536 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -254,7 +254,7 @@ impl<'a> Lexer<'a> { // An attribute path is ident-cont runs interleaved with `[...]` // interpolation groups: `data.tenants[subject.tenant].data_region`. // The bracket content is a nested attribute key the evaluator - // resolves at eval time (R3b) — the lexer only delimits it. + // resolves at eval time — the lexer only delimits it. let mut has_bracket = false; loop { while let Some(b) = self.peek() { @@ -1699,8 +1699,7 @@ fn parse_restrict_effect(body: &serde_yaml::Value, source: &str) -> Result Result Result, String> { /// Parse a YAML value expected to be a flat map of `label: value` /// pairs (the `custom` field). Scalar values (string / bool / number) -/// are coerced to their string form, matching the label-map contract -/// (design §2.3.1) — `custom` is equality-matched labels, not typed -/// values. +/// are coerced to their string form: `custom` is equality-matched +/// labels, not typed values. fn parse_label_map( v: &serde_yaml::Value, ) -> Result, String> { @@ -2944,7 +2942,7 @@ mod tests { assert!(format!("{}", err).contains("expected `==`")); } - // ----- R3b: interpolated attribute paths ----- + // ----- interpolated attribute paths ----- #[test] fn lex_interpolated_path_is_one_ident() { @@ -3833,7 +3831,7 @@ sequential: assert!(format!("{}", err).contains("empty")); } - // ----- R1: restrict effect ----- + // ----- restrict effect ----- /// A literal `StringSetSpec` for terse assertions. fn lit(items: &[&str]) -> Option { @@ -3945,7 +3943,7 @@ do: #[test] fn restrict_inside_pdp_on_allow() { // `restrict` composes in a PDP reaction — authz says yes, then - // pin routing (design §2.1). + // pin routing. let yaml = r#" cedar: action: read diff --git a/crates/apl-core/src/plugin_decl.rs b/crates/apl-core/src/plugin_decl.rs index d5a536f2..2ad0ab2f 100644 --- a/crates/apl-core/src/plugin_decl.rs +++ b/crates/apl-core/src/plugin_decl.rs @@ -140,7 +140,7 @@ impl<'a> EffectivePlugin<'a> { /// the effective view. Returns `None` if `name` isn't in the /// registry — caller decides whether that's an error. /// - /// Spec §"Route-Level Plugin Config Overrides": + /// Route-level override precedence: /// - Override `config` replaces the global `config` entirely. /// - Override `capabilities` replaces global capabilities. /// - Override `on_error` replaces global on_error. diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs index 76582c07..9159c6e9 100644 --- a/crates/apl-core/src/route.rs +++ b/crates/apl-core/src/route.rs @@ -71,7 +71,7 @@ pub struct RouteDecision { /// Backend candidate constraints emitted by `restrict` effects in any /// phase. Empty unless a `restrict` fired. The host bridge (apl-cpex) /// folds these into a `CandidateConstraintExtension` it serializes to - /// the router — see `docs/apl-restrict-effect-design.md` §2.5. + /// the router. pub constraints: Vec, /// True if any args field was rewritten or omitted. pub args_modified: bool, diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs index cf94d2ac..95c50c8b 100644 --- a/crates/apl-core/src/rules.rs +++ b/crates/apl-core/src/rules.rs @@ -190,7 +190,7 @@ pub enum Effect { /// picks a backend and never allows/denies. Accumulating, in the same /// family as `Taint`: the evaluator collects the constraint, a higher /// layer folds it into a `CandidateConstraintExtension` the host - /// serializes to its router. See docs/apl-restrict-effect-design.md. + /// serializes to its router. Restrict { spec: crate::constraint::RestrictSpec, }, diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index a46321b1..6f6e65c8 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -76,8 +76,7 @@ pub(crate) enum Step { /// succeeds; never produces a Deny (accumulating, same family as /// `Taint`). The evaluator collects the emitted constraint; a higher /// layer (apl-cpex) folds it into a `CandidateConstraintExtension` - /// the host serializes to its router. See - /// `docs/apl-restrict-effect-design.md`. + /// the host serializes to its router. Restrict { spec: crate::constraint::RestrictSpec, }, @@ -121,9 +120,8 @@ pub(crate) enum Step { /// rules. /// /// For fan-out flows that need multiple independently-queryable -/// grants, split into `pre_invocation:` + `post_invocation:` or reach for a -/// future per-step `as:` alias (not in v0; see the design doc's -/// "Open design questions" section). +/// grants, split into `pre_invocation:` + `post_invocation:`. There is +/// no per-step alias for naming an individual delegate's grants. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DelegateStep { /// Plugin name — must reference an entry in the top-level diff --git a/crates/apl-cpex/src/attribute_source.rs b/crates/apl-cpex/src/attribute_source.rs index 54692240..918c9811 100644 --- a/crates/apl-cpex/src/attribute_source.rs +++ b/crates/apl-cpex/src/attribute_source.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// FileAttributeSource — the built-in `data.*` provider (design §4.4.1). +// FileAttributeSource — the built-in `data.*` provider. // // Reads a list of attribute files (YAML, each wrapping everything under a // top-level `data:` mapping) and deep-merges them into one diff --git a/crates/apl-cpex/src/candidate_constraint.rs b/crates/apl-cpex/src/candidate_constraint.rs index 19f1479c..8ffa13f4 100644 --- a/crates/apl-cpex/src/candidate_constraint.rs +++ b/crates/apl-cpex/src/candidate_constraint.rs @@ -8,7 +8,6 @@ // wire type) the host router reads off the returned `Extensions`. This is // the bridge between the pure policy language and the framework's typed // extension slot, the same role `apply_session_taints` plays for taints. -// See docs/apl-restrict-effect-design.md §2.4/§2.5. use apl_core::constraint::{CandidateConstraint, OnEmpty as AplOnEmpty}; use cpex_core::extensions::{CandidateConstraintExtension, OnEmpty}; @@ -41,7 +40,7 @@ impl std::fmt::Display for ConstraintConflict { /// to an unrestricting result). Order-independent — the input may arrive /// in any order (constraints from parallel branches merge unsorted). /// -/// Monotone semantics (design §2.4): allow-sets **intersect**, +/// Monotone semantics: allow-sets **intersect**, /// `deny_models` **union**, `max_cost_tier` ceilings **collect** into /// `max_cost_tiers` (CPEX can't order tier names — the host reduces to the /// min), `custom` **union**, `on_empty` takes the **strictest** diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index d0231a23..7af3bcb3 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -415,7 +415,7 @@ impl AnyHookHandler for AplRouteHandler { // No-op when no taints emitted. invoker.apply_session_taints(&decision.taints).await; - // R2: fold this request's `restrict` constraints into one typed + // Fold this request's `restrict` constraints into one typed // `CandidateConstraintExtension`. A custom-label contradiction // (two restricts requiring the same label to differ) cannot be // honored by any backend, so it fails closed below (mirrors the @@ -491,11 +491,11 @@ impl AnyHookHandler for AplRouteHandler { None }; - // R2: write the folded constraint into the typed + // Write the folded constraint into the typed // `candidate_constraint` extension slot so the host router reads // it TYPED off `PipelineResult.modified_extensions` — the same // in-process, type-shared channel `raw_credentials.delegated_tokens` - // rides (design §2.5). `extensions_changed` doesn't track this + // rides. `extensions_changed` doesn't track this // slot, so we force `modified_extensions` to `Some` here to // guarantee the constraint reaches the executor's merge. if let Some(constraint) = folded_constraint { @@ -595,7 +595,7 @@ impl AnyHookHandler for AplRouteHandler { } } - // R2 fail-closed: a `restrict` custom-label contradiction means no + // Fail closed: a `restrict` custom-label contradiction means no // backend can satisfy the request's routing constraints. Deny // rather than emit an unhonorable constraint. On an already-denied // request, keep the original policy attribution (same precedence diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs index c06a7a50..a72a8c0c 100644 --- a/crates/apl-cpex/src/session_resolver.rs +++ b/crates/apl-cpex/src/session_resolver.rs @@ -100,8 +100,8 @@ fn short_hash(raw: &str) -> String { /// module doc prescribes for the (previously raw) Agent and TokenClaim tiers, /// so a session id chosen by one principal cannot address another principal's /// session bucket. Returns `None` when there is no authenticated subject — a -/// bare client value has no safe scope, consistent with Tiers 2/3, which also -/// require a subject. +/// bare client value has no safe scope, consistent with the identity tier, +/// which also requires a subject. fn subject_scoped(subject_id: Option<&str>, raw: &str) -> Option { let sub = subject_id?; Some(short_hash(&format!("{}:{}", sub, raw))) @@ -323,9 +323,9 @@ mod tests { #[test] fn tier0_wins_over_identity() { - // T0 (agent.session_id) must win over T2 (identity triple) when - // both are available. Pins the tier priority explicitly so a - // future refactor of the resolver's walk order regresses loudly. + // The agent tier (`agent.session_id`) must win over the identity + // tier (identity triple) when both are available. Pins the walk + // order explicitly so a future refactor regresses loudly. let mut agent = AgentExtension::default(); agent.session_id = Some("from-agent".into()); let sec = SecurityExtension { @@ -350,7 +350,7 @@ mod tests { assert_eq!( src, SessionSource::Agent, - "T0 must win over T2 when both are available", + "agent tier must win over identity tier when both are available", ); assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap()); } @@ -394,11 +394,11 @@ mod tests { #[test] fn tier1_same_session_id_claim_different_subjects_are_distinct() { - // The guarantee for T1. An issuer that reuses a - // session_id value across multiple principals (multi-tenant - // naming conventions, counters that don't carry the subject, - // etc.) must NOT let one principal land in another's session - // bucket. Direct mirror of the T0 cross-principal test. + // The cross-principal guarantee for the token-claim tier. An + // issuer that reuses a session_id value across multiple principals + // (multi-tenant naming conventions, counters that don't carry the + // subject, etc.) must NOT let one principal land in another's + // session bucket. Direct mirror of the agent-tier test above. let mk = |sub: &str| -> SecurityExtension { SecurityExtension { subject: Some(subject_with_claims( @@ -438,9 +438,9 @@ mod tests { #[test] fn tier1_no_subject_id_falls_through() { // A JWT carries a `session_id` claim but has no `sub` (subject - // present but `id == None`). T1 has no safe scope without a - // subject — must fall through. T2 also requires a subject and - // therefore returns None overall. + // present but `id == None`). The token-claim tier has no safe + // scope without a subject — must fall through. The identity tier + // also requires a subject, so resolution returns None overall. let sec = SecurityExtension { subject: Some(SubjectExtension { id: None, @@ -461,9 +461,9 @@ mod tests { #[test] fn tier1_wins_over_identity() { // Both a JWT session_id claim AND a full identity triple are - // present. T1 must win over T2. Pins the tier priority - // explicitly — the existing happy-path test happens to omit - // T2 inputs, so without this T1>T2 priority is only implicit. + // present. The token-claim tier must win over the identity tier. + // Pins the priority explicitly — the happy-path test above omits + // identity-tier inputs, so otherwise the ordering is only implicit. let sec = SecurityExtension { subject: Some(subject_with_claims( Some("alice"), @@ -485,12 +485,13 @@ mod tests { assert_eq!( src, SessionSource::TokenClaim, - "T1 must win over T2 when both are available", + "token-claim tier must win over identity tier when both are available", ); assert_eq!(sid, subject_scoped(Some("alice"), "from-claim").unwrap()); } - // Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent. + // The client-supplied `X-CPEX-Session-Id` header tier is + // intentionally absent — it has no slot in the walk above. // // The Python `SessionResolver` included a header tier; cpex Rust // does not. See the module-level doc comment for the threat model. diff --git a/crates/apl-cpex/tests/attribute_source_e2e.rs b/crates/apl-cpex/tests/attribute_source_e2e.rs index e760cd3d..34f3dd90 100644 --- a/crates/apl-cpex/tests/attribute_source_e2e.rs +++ b/crates/apl-cpex/tests/attribute_source_e2e.rs @@ -5,8 +5,8 @@ // // End-to-end: a static `data.*` attribute tree, set on the visitor before // the config walk, flows into every request's bag so policy predicates can -// read it. Covers R3 of docs/apl-restrict-effect-design.md — the load → -// bag path (static dot-path references; R3b interpolation is separate). +// read it. Covers the load → bag path for static dot-path references; +// `${...}` interpolation is exercised separately below. use std::sync::Arc; @@ -113,7 +113,7 @@ async fn missing_data_key_is_absent_not_error() { ); } -// ----- R3b: interpolation end-to-end ----- +// ----- interpolated attribute paths, end-to-end ----- /// Invoke with a subject id so `subject.id` lands in the bag. async fn invoke_as_subject(mgr: &Arc, tool: &str, subject_id: &str) -> bool { diff --git a/crates/apl-cpex/tests/restrict_e2e.rs b/crates/apl-cpex/tests/restrict_e2e.rs index 9ff9d9a7..aebe440c 100644 --- a/crates/apl-cpex/tests/restrict_e2e.rs +++ b/crates/apl-cpex/tests/restrict_e2e.rs @@ -7,8 +7,7 @@ // real PluginManager + APL visitor, must fold the emitted constraints // and surface them on the typed `candidate_constraint` extension slot // that the host router reads off `PipelineResult.modified_extensions`. -// A `custom`-label contradiction must fail closed. Covers R2 of -// docs/apl-restrict-effect-design.md. +// A `custom`-label contradiction must fail closed. use std::sync::Arc; diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 8c9ff4c5..f758f430 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -2336,7 +2336,7 @@ groups: #[test] fn route_joining_unknown_group_is_rejected() { - // A5: a typo'd `groups:` value must fail at load, not silently + // A typo'd `groups:` value must fail at load, not silently // leave the route without the group's authentication. let yaml = r#" plugin_settings: diff --git a/crates/cpex-core/src/extensions/routing.rs b/crates/cpex-core/src/extensions/routing.rs index d0787fd2..8d197933 100644 --- a/crates/cpex-core/src/extensions/routing.rs +++ b/crates/cpex-core/src/extensions/routing.rs @@ -13,7 +13,6 @@ // type-shared channel `raw_credentials.delegated_tokens` rides — and // narrows its candidate set accordingly. It is a routing directive, not // an access decision: it never picks a backend and never allows/denies. -// See docs/apl-restrict-effect-design.md. use std::collections::{BTreeMap, HashMap}; @@ -47,7 +46,7 @@ pub enum OnEmpty { /// to `max_cost_tiers` (the *set* of ceilings). CPEX cannot order tier /// names — that ordering is host-owned — so it emits every ceiling and /// the host requires `cost_tier ≤ all of them`, which is `≤ min` once the -/// host applies its own tier order. See the design doc §2.5.1. +/// host applies its own tier order. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct CandidateConstraintExtension { /// Candidate `model` must be in this set (glob-matched). `None` = no @@ -98,7 +97,7 @@ impl CandidateConstraintExtension { } /// Does `backend` satisfy this constraint? This is the executable half - /// of the seam contract (design §2.6) — the host router (Praxis) calls + /// of the seam contract — the host router (Praxis) calls /// it per candidate to prune its eligible set, instead of /// reimplementing the matcher. All field semantics live here, once: /// diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index b8dba0ac..b752f7c0 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -4270,7 +4270,7 @@ routes: assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry } - /// Regression (A1, typed path): `load_config_yaml` used to deserialize + /// Regression (typed path): `load_config_yaml` used to deserialize /// `CpexConfig` directly and skip `parse_config`'s normalization, so a /// top-level `groups:` bundle never folded into `global.policies` and a /// route joining it lost the group's plugins. Here the deny plugin lives @@ -4316,7 +4316,7 @@ routes: assert_eq!(result.violation.as_ref().unwrap().code, "denied"); } - /// Regression (A1, visitor path): the visitor walk read only + /// Regression (visitor path): the visitor walk read only /// `global.policies`, so a top-level `groups:` bundle's `authorization:` /// was never compiled. This registers a visitor that records which /// bundles it was asked to compile and asserts the top-level group is From 08e1863d90d6d3187f6a00e8ede81c45955f5067 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 5 Aug 2026 23:02:25 -0400 Subject: [PATCH 2/2] docs: tighten identity & delegation page Cut repeated caveats and filler, fix a duplicated paragraph, and reserve bold for labels. Recipe headings now use colons, so their anchors and the links to them changed. Signed-off-by: Frederico Araujo --- docs/content/docs/identity-delegation.md | 123 +++++++++++------------ 1 file changed, 57 insertions(+), 66 deletions(-) diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md index ce22f504..162d0349 100644 --- a/docs/content/docs/identity-delegation.md +++ b/docs/content/docs/identity-delegation.md @@ -23,7 +23,7 @@ For the conceptual model first, read [Use Cases]({{< relref "use-cases" >}}) and Every request crosses two identity boundaries: -![Two identity boundaries: an inbound identity.resolve box (who is calling in) that validates credentials and lands typed identity slots, an arrow labelled route + policy, and an outbound token.delegate box (who we call out as) that mints the downstream credential per the route + subject; identity is additive across slots while delegation is chosen per route by subject](images/identity_two_boundaries.png) +![Two identity boundaries. Inbound: identity.resolve validates credentials and fills typed identity slots, additively. Outbound: token.delegate mints the downstream credential, chosen per route by subject.](images/identity_two_boundaries.png) - **Inbound.** `identity.resolve` plugins each read one credential (from a header) and land a typed identity in a slot. They are additive: one request can carry a @@ -68,14 +68,12 @@ The `subject:` on a `delegate(...)` step decides the OAuth mechanism the delegat --- -## Scoping — how broadly to apply it +## Scoping: how broadly to apply it CPEX resolves the pipeline for each request across one **broad → narrow stack**, and **both identity and policy (including delegation) ride it**. Narrower layers add to (or override) broader ones. Pick the broadest layer that's still correct. -The layers, broad to narrow: - A **group** is a named, reusable bundle of policy (authentication steps + authorization steps + plugins) that routes opt into. The layers, broad to narrow: @@ -86,7 +84,7 @@ authorization steps + plugins) that routes opt into. The layers, broad to narrow | **Group** | routes that join `` (via `groups:` or a matching tag) | `groups..authentication` | `groups..authorization` / `plugins` | | **Route (entity)** | one route (a `tool: "*"` route is the catch-all) | route `authentication:` | route `authorization:` steps / `plugins:` | -So a `delegate(...)` is **not** route-only. To pick its breadth, put it (or a +So a `delegate(...)` is *not* route-only. To pick its breadth, put it (or a `token.delegate` plugin) at the matching layer: - **every tool** → a `delegate()` in a `tool: "*"` route, or the delegator plugin in @@ -117,8 +115,7 @@ routes: `groups: hr-tools` is the first-class way to join a group, and it is **sugar over tags**: `meta: { tags: [hr-tools] }` is exactly equivalent, and host-injected runtime -tags join groups the same way. Tags stay the substrate — `groups:` just names the -common case. +tags join groups the same way. **The override.** A route that must stand alone drops the inherited layers: @@ -130,7 +127,7 @@ routes: steps: [jwt-workload] # …authenticate by the SVID alone ``` -That is what [Recipe 2](#recipe-2--agent-acting-as-itself-by-its-spiffe-svid) +That is what [Recipe 2](#recipe-2-agent-acting-as-itself-by-its-spiffe-svid) uses. (Full group / defaults syntax: [Configuration]({{< relref "configuration" >}}).) ### Rule of thumb @@ -150,11 +147,11 @@ Each recipe is a drop-in: the plugins it needs, the route layout, and where it h been run. All config is [unified-config]({{< relref "configuration" >}}) YAML. > **Canonical keys.** These recipes write policy under `authorization:` (with -> `pre_invocation:` / `post_invocation:` inside) — the orchestrator-agnostic spelling. +> `pre_invocation:` / `post_invocation:` inside), the orchestrator-agnostic spelling. > The older `apl:` wrapper is still accepted, and `pre_invocation:` may also be written > flat on the route; all three compile identically. -### Recipe 1 — User acting through an agent (on-behalf-of) +### Recipe 1: User acting through an agent (on-behalf-of) **When:** a human is signed in; the agent calls a downstream API *as that user*. CPEX exchanges the user's IdP token for a downstream-audience token. @@ -196,7 +193,7 @@ routes: The minted `workday-api` token is attached to the upstream call. **Tested: Keycloak 26.x (Standard Token Exchange v2).** -### Recipe 2 — Agent acting as itself, by its SPIFFE SVID +### Recipe 2: Agent acting as itself, by its SPIFFE SVID **When:** the *agent* is the principal (no human), and you don't trust the agent to hold downstream authority. The agent presents its SVID; CPEX brokers a scoped @@ -207,7 +204,7 @@ downstream token. The agent holds no standing entitlement to the target. > credential, not an OAuth access token. It can't be forwarded to the downstream or > used as a bearer/subject token as-is; CPEX has to **turn it into an IdP-issued token > first** (leg 1 below). Contrast -> [Recipe 5](#recipe-5--scope-a-token-the-agent-already-holds-1-leg), whose input is a +> [Recipe 5](#recipe-5-scope-a-token-the-agent-already-holds-1-leg), whose input is a > token already *minted from* an SVID. Add a workload resolver, scoped to the route so only it runs there: @@ -253,7 +250,7 @@ consult your IdP's SPIFFE client-auth docs. **Tested: Keycloak 26.6 (feature > CPEX the trust boundary. A compromised agent can prove who it is but cannot mint > the downstream token itself. -### Recipe 3 — A service acting as itself +### Recipe 3: A service acting as itself **When:** CPEX calls a downstream as *itself*, with no inbound credential to exchange (e.g. a scheduled job, or CPEX's own housekeeping). @@ -270,14 +267,14 @@ routes: `subject_token`, CPEX's own `client_id`/secret is the identity. **Tested: Keycloak (client_credentials).** -### Recipe 4 — Forward a token the caller already has (passthrough) +### Recipe 4: Forward a token the caller already has (passthrough) **When:** the agent authenticated to the IdP itself and hands CPEX a ready token. CPEX validates it inbound and lets the route forward it, with no `delegate` step. This is the "agent-brokered" case; it needs no delegation code, only that the inbound resolver validates the token and the route allows the call. -### Recipe 5 — Scope a token the agent already holds (1-leg) +### Recipe 5: Scope a token the agent already holds (1-leg) **When:** the agent authenticated to the IdP *itself* with its SVID and got back a normal JWT, and you still want CPEX to narrow that token per-tool (least privilege @@ -309,7 +306,7 @@ routes: ``` This is a **plain RFC 8693 exchange**, the same engine as -[Recipe 1](#recipe-1--user-acting-through-an-agent-on-behalf-of), scoping the +[Recipe 1](#recipe-1-user-acting-through-an-agent-on-behalf-of), scoping the *agent's* token instead of a user's. **One leg** (the scope): the agent did the authenticate leg upstream, so CPEX doesn't. @@ -318,29 +315,25 @@ an SVID, or a token minted from one: | Agent presents | Slot → subject | CPEX does | Legs | |---|---|---|---| -| its **SVID** (`ES256`, SPIRE JWKS) | `caller_workload` → `subject: caller_workload` | authenticate **+** scope | 2 (Recipe 2) | +| its **SVID** (`ES256`, SPIRE JWKS) | `caller_workload` → `subject: caller_workload` | authenticate + scope | 2 (Recipe 2) | | a **token minted from its SVID** (`RS256`, IdP JWKS) | `client` → `subject: client` | scope only | 1 (this recipe) | | a **token already right** for the tool | — | forward as-is | 0 (Recipe 4) | Using `subject: caller_workload` on an already-minted token misroutes it -down the SVID two-leg (`client_assertion`) path. Match the subject to what -arrived: **an SVID is a `caller_workload`; a JWT minted from it is a -`client` (or `user`) token.** +down the two-leg `client_assertion` path. -### Recipe 6 — User acting through an agent, with the agent named (dual-principal) +### Recipe 6: User acting through an agent, with the agent named (dual-principal) **When:** a human is signed in *and* you want the record to name the agent that carried out the call. The minted token speaks for the user (`sub`), and CPEX -additionally names the calling agent as the RFC 8693 acting party (`act`) — so a token -service that honors delegation records **both** who authorized the action and who -performed it. This is the common agentic shape: the human decides, the agent acts. -(Whether the `act` claim actually lands depends on the token service — see the interop -note.) - -It composes two inbound resolvers you have already met — `jwt-user` -([Recipe 1](#recipe-1--user-acting-through-an-agent-on-behalf-of)) for the human on +additionally names the calling agent as the RFC 8693 acting party (`act`), so a token +service that honors delegation records both who authorized the action and who +performed it. + +It composes two inbound resolvers: `jwt-user` +([Recipe 1](#recipe-1-user-acting-through-an-agent-on-behalf-of)) for the human on `X-User-Token`, and `jwt-workload` -([Recipe 2](#recipe-2--agent-acting-as-itself-by-its-spiffe-svid)) for the agent's +([Recipe 2](#recipe-2-agent-acting-as-itself-by-its-spiffe-svid)) for the agent's SVID on `X-Workload-Token`. Both must resolve; both credentials arrive on every call. ```yaml @@ -359,63 +352,61 @@ routes: ``` `subject: user` makes the user's token the RFC 8693 `subject_token` (exactly as -[Recipe 1](#recipe-1--user-acting-through-an-agent-on-behalf-of)); `actor: +[Recipe 1](#recipe-1-user-acting-through-an-agent-on-behalf-of)); `actor: caller_workload` *additionally* attaches the agent's SVID as the `actor_token`, -**requesting** that the minted token carry `act` alongside `sub`. It's **one exchange -call, two principals in the request** — not a second leg. `actor` accepts only inbound -credentials (`user`, `client`, `caller_workload`): the acting party is by definition -one that presented itself to CPEX. Whether `act` actually lands in the token is the -token service's call — see the interop note below. - -> **Subject vs. actor.** The *subject* is who the token speaks **for** (whose -> authority); the *actor* is who is **doing** it (attribution). Least-privilege scoping -> still follows the subject — the `act` claim records the agent, it doesn't grant it +requesting that the minted token carry `act` alongside `sub`. This is one exchange +call with two principals in the request, not a second leg. `actor` accepts only +inbound credentials (`user`, `client`, `caller_workload`): the acting party is by +definition one that presented itself to CPEX. + +> **Subject vs. actor.** The *subject* is who the token speaks *for* (whose +> authority); the *actor* is who is *doing* it (attribution). Least-privilege scoping +> still follows the subject. The `act` claim records the agent, it doesn't grant it > anything. -**Which actor — `client` or `caller_workload`?** Match it to *how the agent +**Which actor, `client` or `caller_workload`?** Match it to *how the agent authenticated*. An agent that presented a SPIFFE SVID is a `caller_workload` (above); -one that authenticated as a registered OAuth client — an `Authorization` bearer token, -resolved with `role: client` — is `actor: client`: +one that authenticated as a registered OAuth client (an `Authorization` bearer token, +resolved with `role: client`) is `actor: client`: ```yaml - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation], subject: user, actor: client)" ``` -> **Valid combinations.** `actor:` pairs with `subject: user` or `subject: client` -> — the on-behalf-of shape. It is **not** supported with `subject: caller_workload` +> **Valid combinations.** `actor:` pairs with `subject: user` or `subject: client`, +> the on-behalf-of shape. It is **not** supported with `subject: caller_workload` > (the workload is already the subject) or `subject: this_workload` (a -> `client_credentials` grant carries no `actor_token`); CPEX **rejects** those at +> `client_credentials` grant carries no `actor_token`); CPEX rejects those at > config time rather than silently dropping the actor. -**CPEX side — implemented and e2e-tested against a mock IdP.** The delegator puts the +**CPEX side: implemented and e2e-tested against a mock IdP.** The delegator puts the actor on the wire exactly as RFC 8693 delegation prescribes (`actor_token` + -`actor_token_type`), and omits it cleanly when no actor is configured. That half is -correct regardless of which token service receives it. +`actor_token_type`), and omits it when no actor is configured. -> **Interop: `act` is the token service's job — impersonation vs. delegation.** +> **Interop: `act` is the token service's job (impersonation vs. delegation).** > RFC 8693 (§1.1) exchanges come in two flavors. *Impersonation* returns a token that -> speaks purely for the subject — indistinguishable from one the subject fetched -> directly, **no `act` claim**. *Delegation* additionally records the actor in a nested -> `act`. The `actor_token` parameter is what asks for delegation; only a token service -> that implements the delegation path emits `act`. **CPEX always sends the delegation -> request — but the claim only appears if the service honors it.** +> speaks purely for the subject, indistinguishable from one the subject fetched +> directly, with **no `act` claim**. *Delegation* additionally records the actor in a +> nested `act`. The `actor_token` parameter is what asks for delegation; only a token +> service that implements the delegation path emits `act`. **CPEX always sends the +> delegation request, but the claim only appears if the service honors it.** > > **Keycloak does not.** Keycloak's Standard Token Exchange (v2, tested here on 26.6) > implements impersonation only: it **silently ignores `actor_token`** and returns a > subject-only token with no `act`. The tell (probed 2026-07-28): passing even a raw, -> untrusted-issuer SVID as the exchange's `actor_token` produces **no error** — Keycloak -> never parses the parameter, so no mapper or config can surface it. To see `act` end-to-end you need a -> delegation-capable token service; against Keycloak, capture the acting agent at the -> CPEX boundary (audit / downstream header) instead — CPEX resolves both principals -> either way. +> untrusted-issuer SVID as the exchange's `actor_token` produces **no error**. Keycloak +> never parses the parameter, so no mapper or config can surface it. To see `act` +> end-to-end you need a delegation-capable token service; against Keycloak, capture the +> acting agent at the CPEX boundary (audit / downstream header) instead. CPEX resolves +> both principals either way. --- ## Where to place CPEX -The same config runs at any enforcement point. See -[Deployment → Placement guidance]({{< relref "deployment" >}}). The identity-specific read: +See [Deployment → Placement guidance]({{< relref "deployment" >}}). +The identity-specific read: | Placement | Use it when | Because | |---|---|---| @@ -438,11 +429,11 @@ not any one vendor. Per layer: | On-behalf-of exchange | RFC 8693 | Keycloak (STE v2) | IdPs vary in RFC 8693 support; verify per target | | SVID as client credential | RFC 7523 + `draft-ietf-oauth-spiffe-client-auth` | Keycloak 26.6 (`spiffe:v1`) | emerging; an IETF OAuth WG draft, other IdPs not yet confirmed | -**If your IdP doesn't (yet) speak SPIFFE**, you are not blocked. CPEX can validate +**If your IdP doesn't yet speak SPIFFE**, CPEX can still validate the SVID *itself* (it already fetches SPIRE's JWKS in `identity.resolve`), establish `caller_workload`, and then mint the downstream token using its *own* credentials (`subject: this_workload`). Note the trade-off: a `client_credentials` grant carries -**no** caller identity, so the minted token speaks for CPEX, not the agent — capture +no caller identity, so the minted token speaks for CPEX, not the agent. Capture the caller at the CPEX boundary (audit) if the backend needs it. That "CPEX-validates, CPEX-mints-as-itself" mode works with any OIDC IdP today; only the recipe-2 *native* flow needs the IdP to understand SVIDs. @@ -454,8 +445,8 @@ recipe-2 *native* flow needs the IdP to understand SVIDs. ## What to add next