Skip to main content

cpex_core/extensions/
delegation.rs

1// Location: ./crates/cpex-core/src/extensions/delegation.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// DelegationExtension — token delegation chain.
7// Mirrors cpex/framework/extensions/delegation.py.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use super::authorization::AuthorizationDetail;
13use super::security::SubjectType;
14
15/// Delegation strategy used to mint the credential at this hop.
16///
17/// The known variants cover the reference implementations in
18/// docs/specs/delegation-hooks-rust-spec.md §9.5. `Custom(String)` is the
19/// escape hatch for host-defined strategies (UCAN variants, in-house mints).
20/// Marked `#[non_exhaustive]` so new known variants can be added without a
21/// breaking change to host code that exhaustively matches.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum DelegationStrategy {
26    TokenExchange,
27    ClientCredentials,
28    SpiffeSvid,
29    Passthrough,
30    Ucan,
31    TransactionToken,
32    #[serde(untagged)]
33    Custom(String),
34}
35
36/// A single hop in the delegation chain.
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct DelegationHop {
39    /// Subject ID of the delegator.
40    pub subject_id: String,
41
42    /// Subject type of the delegator. Reuses the typed `SubjectType`
43    /// enum from `SecurityExtension.subject`, not a freeform string.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub subject_type: Option<SubjectType>,
46
47    /// Target audience.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub audience: Option<String>,
50
51    /// Scopes granted in this delegation step.
52    #[serde(default)]
53    pub scopes_granted: Vec<String>,
54
55    /// RFC 9396 authorization_details carried alongside scopes.
56    /// Each hop's details must be structurally narrowed from the previous.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub authorization_details: Vec<AuthorizationDetail>,
59
60    /// When this hop was minted. Default is the Unix epoch — production
61    /// code constructs with `Utc::now()`; only tests rely on the default.
62    #[serde(default)]
63    pub timestamp: DateTime<Utc>,
64
65    /// Time-to-live in seconds.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub ttl_seconds: Option<u64>,
68
69    /// Delegation strategy used.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub strategy: Option<DelegationStrategy>,
72
73    /// Whether this hop was resolved from cache.
74    #[serde(default)]
75    pub from_cache: bool,
76}
77
78/// Delegation chain extension.
79///
80/// Append-only — each hop narrows scope. A delegate cannot have
81/// more permissions than the delegator.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct DelegationExtension {
84    /// Ordered delegation chain.
85    #[serde(default)]
86    pub chain: Vec<DelegationHop>,
87
88    /// Chain depth (number of hops). `u32` for wire-stable width.
89    #[serde(default)]
90    pub depth: u32,
91
92    /// Subject ID of the original delegator.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub origin_subject_id: Option<String>,
95
96    /// Subject ID of the current actor.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub actor_subject_id: Option<String>,
99
100    /// Whether delegation has occurred.
101    #[serde(default)]
102    pub delegated: bool,
103
104    /// Age of the delegation chain in seconds.
105    #[serde(default)]
106    pub age_seconds: f64,
107}
108
109impl DelegationExtension {
110    /// Append a delegation hop (monotonic — cannot remove).
111    pub fn append_hop(&mut self, hop: DelegationHop) {
112        self.chain.push(hop);
113        // Cast is safe: a chain with > u32::MAX hops would have failed
114        // memory allocation long ago.
115        self.depth = self.chain.len() as u32;
116        self.delegated = true;
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_delegation_starts_empty() {
126        let del = DelegationExtension::default();
127        assert!(del.chain.is_empty());
128        assert_eq!(del.depth, 0);
129        assert!(!del.delegated);
130    }
131
132    #[test]
133    fn test_append_hop() {
134        let mut del = DelegationExtension::default();
135        del.append_hop(DelegationHop {
136            subject_id: "alice".into(),
137            scopes_granted: vec!["read_hr".into()],
138            ..Default::default()
139        });
140
141        assert_eq!(del.chain.len(), 1);
142        assert_eq!(del.depth, 1);
143        assert!(del.delegated);
144        assert_eq!(del.chain[0].subject_id, "alice");
145        assert_eq!(del.chain[0].scopes_granted, vec!["read_hr"]);
146    }
147
148    #[test]
149    fn test_append_multiple_hops() {
150        let mut del = DelegationExtension {
151            origin_subject_id: Some("alice".into()),
152            ..Default::default()
153        };
154
155        del.append_hop(DelegationHop {
156            subject_id: "alice".into(),
157            audience: Some("service-b".into()),
158            scopes_granted: vec!["read".into(), "write".into()],
159            strategy: Some(DelegationStrategy::TokenExchange),
160            ..Default::default()
161        });
162
163        del.append_hop(DelegationHop {
164            subject_id: "service-b".into(),
165            audience: Some("service-c".into()),
166            scopes_granted: vec!["read".into()], // narrowed scope
167            ..Default::default()
168        });
169
170        assert_eq!(del.chain.len(), 2);
171        assert_eq!(del.depth, 2);
172        // Second hop has narrower scope
173        assert_eq!(del.chain[1].scopes_granted, vec!["read"]);
174    }
175
176    #[test]
177    fn test_strategy_serde_known_and_custom() {
178        // Known variant serializes as snake_case string.
179        let known = DelegationStrategy::TokenExchange;
180        let json = serde_json::to_string(&known).unwrap();
181        assert_eq!(json, "\"token_exchange\"");
182        let back: DelegationStrategy = serde_json::from_str(&json).unwrap();
183        assert_eq!(back, DelegationStrategy::TokenExchange);
184
185        // Custom variant serializes as a bare string (untagged).
186        let custom = DelegationStrategy::Custom("in_house_mint".into());
187        let json = serde_json::to_string(&custom).unwrap();
188        assert_eq!(json, "\"in_house_mint\"");
189        // Deserializing a string that doesn't match a known variant falls
190        // through to Custom — the escape hatch.
191        let back: DelegationStrategy = serde_json::from_str("\"in_house_mint\"").unwrap();
192        assert_eq!(back, DelegationStrategy::Custom("in_house_mint".into()));
193    }
194
195    #[test]
196    fn test_delegation_serde_roundtrip() {
197        let mut del = DelegationExtension {
198            origin_subject_id: Some("alice".into()),
199            actor_subject_id: Some("service-b".into()),
200            ..Default::default()
201        };
202        del.append_hop(DelegationHop {
203            subject_id: "alice".into(),
204            subject_type: Some(SubjectType::User),
205            scopes_granted: vec!["admin".into()],
206            from_cache: true,
207            ..Default::default()
208        });
209
210        let json = serde_json::to_string(&del).unwrap();
211        let deserialized: DelegationExtension = serde_json::from_str(&json).unwrap();
212
213        assert_eq!(deserialized.depth, 1);
214        assert!(deserialized.delegated);
215        assert_eq!(deserialized.origin_subject_id.as_deref(), Some("alice"));
216        assert!(deserialized.chain[0].from_cache);
217    }
218}