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}