Skip to main content

cpex_core/identity/
route_config.rs

1// Location: ./crates/cpex-core/src/identity/route_config.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Route-level identity configuration — the parsed shape of a
7// route's `identity:` block in unified-config YAML.
8//
9// See `docs/apl-identity-delegation-design.md` for the full design.
10//
11// # Semantic note
12//
13// Identity binding is **hook-specific**: the `identity:` block
14// binds plugins ONLY for the `identity.resolve` hook on this
15// route, independent of whatever the route's `plugins:` block does.
16// This matters because in APL-driven routes, the `plugins:` block
17// has different meaning (it's a per-route config-override list,
18// not a dispatch list — APL controls the dispatch). Identity
19// needs its own binding mechanism so the meaning is unambiguous
20// regardless of whether APL is annotating the route.
21//
22// # YAML shapes
23//
24// Two accepted forms parse to the same IR. The visitor / parser
25// logic in `crate::config` discriminates them.
26//
27// ```yaml
28// # List form — implicit additive, common case
29// identity:
30//   - corp-jwt
31//   - spiffe-attestor
32//
33// # Object form — when the override flag is needed
34// identity:
35//   replace_inherited: true
36//   steps:
37//     - legacy-basic-auth
38// ```
39//
40// Each step is either a bare plugin name (string) or a map with
41// `name:` + optional `on_error:` / `config:`:
42//
43// ```yaml
44// identity:
45//   - corp-jwt                       # bare name
46//   - name: spiffe-attestor          # map form
47//     on_error: deny
48//     config:
49//       verify_attestation: strict
50// ```
51
52use std::collections::HashMap;
53
54use serde::{Deserialize, Serialize};
55
56/// A route's parsed `identity:` block. Drives dispatch of the
57/// `identity.resolve` hook for the route.
58///
59/// `None` on a `RouteEntry` means "no identity declared for this
60/// route" — `invoke_named::<IdentityHook>` will return an empty
61/// entry list when filtered for this route, and the host's
62/// `IdentityPayload` flows through unchanged (no resolvers fire).
63///
64/// Inheritance (Slice C, deferred) walks `global → tags → route`
65/// and merges each layer's `RouteIdentityConfig` based on
66/// `replace_inherited`: when `false` (the default), the new layer's
67/// steps append after the inherited ones; when `true`, the new
68/// layer's steps replace the inherited list wholesale.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct RouteIdentityConfig {
71    /// Ordered list of identity steps to run. Empty list is valid:
72    /// `identity: { replace_inherited: true, steps: [] }` is the
73    /// "explicitly opt out of inherited identity" knob.
74    pub steps: Vec<RouteIdentityStep>,
75
76    /// When true, this block replaces any inherited identity steps
77    /// instead of appending to them. Set via the object-form YAML
78    /// (`identity: { replace_inherited: true, steps: [...] }`).
79    /// The list-form YAML always produces `false`.
80    ///
81    /// Honored by the inheritance merge once Slice C lands. Slice A
82    /// stores the flag without exercising its merge semantics (no
83    /// inheritance to override yet at route level).
84    #[serde(default, skip_serializing_if = "is_false")]
85    pub replace_inherited: bool,
86}
87
88/// One step in the identity-phase pipeline. Points at a plugin
89/// registered under the `identity.resolve` hook, optionally with
90/// a per-call config override and an `on_error` policy that
91/// controls what happens when the step fails.
92///
93/// # Cumulative stacking
94///
95/// At runtime, every step in the block runs (subject to its own
96/// `on_error`). Each step's resolved `IdentityPayload` accumulates
97/// — handlers contribute orthogonal slots (JWT → `subject`;
98/// SPIFFE → `caller_workload`; agent resolver → `agent`) so they
99/// compose without collision in the common case.
100///
101/// # On-error semantics
102///
103/// - `None` or `Some("continue")` — soft failure: the step's
104///   contribution is dropped, the next step runs, and any missing
105///   extensions get caught later by `require(authenticated)` /
106///   `require(workload.*)` in downstream policy.
107/// - `Some("deny")` — hard requirement: a failure halts the
108///   request with the plugin's violation code.
109///
110/// Unknown strings parse as best-effort; future slices may
111/// introduce typed enums.
112///
113/// # Per-step config override
114///
115/// `config_override` reuses the existing per-call override
116/// pathway. When present, the framework's
117/// `create_override_instance` builds a new plugin instance with
118/// the merged config and dispatches into it for this route.
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct RouteIdentityStep {
121    /// Plugin name — must match an entry in the top-level
122    /// `plugins:` block that registers under `identity.resolve`.
123    pub name: String,
124
125    /// Optional config override applied for this step only.
126    /// `None` means "use the plugin's configured defaults from the
127    /// `plugins:` declaration." Stored as `serde_json::Value` to
128    /// match the existing `create_override_instance` interface.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub config_override: Option<serde_json::Value>,
131
132    /// Per-step failure handling. See type-level docs.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub on_error: Option<String>,
135
136    /// Catch-all for any other fields a future schema version
137    /// adds (timeout, priority, condition, …) — preserved so the
138    /// parser doesn't reject configs targeting newer runtimes.
139    #[serde(default, flatten, skip_serializing_if = "HashMap::is_empty")]
140    pub extra: HashMap<String, serde_json::Value>,
141}
142
143impl RouteIdentityStep {
144    /// Convenience for tests / programmatic construction: build a
145    /// bare step that just names a plugin with no overrides.
146    pub fn bare(name: impl Into<String>) -> Self {
147        Self {
148            name: name.into(),
149            ..Default::default()
150        }
151    }
152}
153
154/// `#[serde(skip_serializing_if = "is_false")]` helper — keeps
155/// the YAML round-trip clean by omitting the default `false`.
156fn is_false(b: &bool) -> bool {
157    !*b
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn bare_step_has_no_overrides() {
166        let s = RouteIdentityStep::bare("corp-jwt");
167        assert_eq!(s.name, "corp-jwt");
168        assert!(s.config_override.is_none());
169        assert!(s.on_error.is_none());
170        assert!(s.extra.is_empty());
171    }
172
173    #[test]
174    fn config_default_is_empty_additive() {
175        let c = RouteIdentityConfig::default();
176        assert!(c.steps.is_empty());
177        assert!(!c.replace_inherited);
178    }
179
180    #[test]
181    fn serializes_without_default_replace_inherited() {
182        // `replace_inherited: false` should round-trip as absent —
183        // it's the default and clutters the YAML otherwise.
184        let c = RouteIdentityConfig {
185            steps: vec![RouteIdentityStep::bare("corp-jwt")],
186            replace_inherited: false,
187        };
188        let yaml = serde_yaml::to_string(&c).unwrap();
189        assert!(!yaml.contains("replace_inherited"), "got: {yaml}");
190        assert!(yaml.contains("corp-jwt"), "got: {yaml}");
191    }
192
193    #[test]
194    fn serializes_with_explicit_replace_inherited() {
195        let c = RouteIdentityConfig {
196            steps: vec![RouteIdentityStep::bare("legacy-basic-auth")],
197            replace_inherited: true,
198        };
199        let yaml = serde_yaml::to_string(&c).unwrap();
200        assert!(yaml.contains("replace_inherited: true"), "got: {yaml}");
201    }
202}