1use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use std::collections::HashMap;
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
37pub struct DynamicMap(pub HashMap<String, Value>);
38
39impl DynamicMap {
40 pub fn new() -> Self {
41 Self(HashMap::new())
42 }
43
44 pub fn with(mut self, key: &str, value: &str) -> Self {
46 self.0.insert(key.into(), Value::String(value.into()));
47 self
48 }
49
50 pub fn with_map(mut self, key: &str, value: DynamicMap) -> Self {
52 self.0
53 .insert(key.into(), serde_json::to_value(value).unwrap());
54 self
55 }
56
57 pub fn with_value(mut self, key: &str, value: Value) -> Self {
59 self.0.insert(key.into(), value);
60 self
61 }
62
63 pub fn with_array(mut self, key: &str, values: Vec<&str>) -> Self {
65 let arr: Vec<Value> = values
66 .into_iter()
67 .map(|s| Value::String(s.into()))
68 .collect();
69 self.0.insert(key.into(), Value::Array(arr));
70 self
71 }
72
73 pub fn get(&self, key: &str) -> Option<&Value> {
74 self.0.get(key)
75 }
76
77 pub fn get_str(&self, key: &str) -> Option<&str> {
78 self.0.get(key)?.as_str()
79 }
80
81 pub fn get_map(&self, key: &str) -> Option<DynamicMap> {
82 let value = self.0.get(key)?;
83 serde_json::from_value(value.clone()).ok()
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.0.is_empty()
88 }
89}
90
91pub type ExecutorBinding = DynamicMap;
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct Executor {
97 pub binding: ExecutorBinding,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct CatProvenance {
106 pub kid: String,
108 #[serde(with = "serde_bytes")]
110 pub signature: Vec<u8>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct ExecutorProvenance {
118 pub kid: String,
120 #[serde(with = "serde_bytes")]
122 pub signature: Vec<u8>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub struct Provenance {
130 pub cat: CatProvenance,
131 pub executor: ExecutorProvenance,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct TemporalConstraints {
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub iat: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub exp: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub nbf: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150pub struct Constraints {
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub temporal: Option<TemporalConstraints>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct PcaPayload {
161 pub hop: u32,
163 pub p_0: String,
165 pub ops: Vec<String>,
167 pub executor: Executor,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub provenance: Option<Provenance>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub constraints: Option<Constraints>,
175}
176
177impl PcaPayload {
178 pub fn to_cbor(&self) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
180 cbor4ii::serde::to_vec(Vec::new(), self).map_err(|e| e.into())
181 }
182
183 pub fn from_cbor(bytes: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
185 cbor4ii::serde::from_slice(bytes).map_err(|e| e.into())
186 }
187
188 pub fn to_json(&self) -> Result<String, serde_json::Error> {
190 serde_json::to_string(self)
191 }
192
193 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
195 serde_json::to_string_pretty(self)
196 }
197
198 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
200 serde_json::from_str(json)
201 }
202
203 pub fn is_origin(&self) -> bool {
205 self.hop == 0
206 }
207
208 pub fn allows_ops(&self, requested_ops: &[String]) -> bool {
210 requested_ops.iter().all(|op| self.ops.contains(op))
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn sample_pca_0() -> PcaPayload {
219 let binding = ExecutorBinding::new().with("org", "acme-corp");
220
221 PcaPayload {
222 hop: 0,
223 p_0: "https://idp.example.com/users/alice".into(),
224 ops: vec!["read:/user/*".into(), "write:/user/*".into()],
225 executor: Executor { binding },
226 provenance: None,
227 constraints: Some(Constraints {
228 temporal: Some(TemporalConstraints {
229 iat: Some("2025-12-11T10:00:00Z".into()),
230 exp: Some("2025-12-11T11:00:00Z".into()),
231 nbf: None,
232 }),
233 }),
234 }
235 }
236
237 fn sample_pca_n() -> PcaPayload {
238 let binding = ExecutorBinding::new().with("org", "acme-corp");
239
240 PcaPayload {
241 hop: 2,
242 p_0: "https://idp.example.com/users/alice".into(),
243 ops: vec!["read:/user/*".into()],
244 executor: Executor { binding },
245 provenance: Some(Provenance {
246 cat: CatProvenance {
247 kid: "https://cat.acme-corp.com/keys/1".into(),
248 signature: vec![0u8; 64],
249 },
250 executor: ExecutorProvenance {
251 kid: "spiffe://acme-corp/ns/prod/sa/archive".into(),
252 signature: vec![0u8; 64],
253 },
254 }),
255 constraints: Some(Constraints {
256 temporal: Some(TemporalConstraints {
257 iat: Some("2025-12-11T10:00:00Z".into()),
258 exp: Some("2025-12-11T10:30:00Z".into()),
259 nbf: None,
260 }),
261 }),
262 }
263 }
264
265 #[test]
266 fn test_pca_0_cbor_roundtrip() {
267 let pca = sample_pca_0();
268 let cbor = pca.to_cbor().unwrap();
269 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
270 assert_eq!(pca, decoded);
271 assert_eq!(decoded.hop, 0);
272 assert!(decoded.provenance.is_none());
273 assert!(decoded.is_origin());
274 }
275
276 #[test]
277 fn test_pca_n_cbor_roundtrip() {
278 let pca = sample_pca_n();
279 let cbor = pca.to_cbor().unwrap();
280 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
281 assert_eq!(pca, decoded);
282 assert_eq!(decoded.hop, 2);
283 assert!(decoded.provenance.is_some());
284 assert!(!decoded.is_origin());
285 }
286
287 #[test]
288 fn test_provenance_uses_kid() {
289 let pca = sample_pca_n();
290 let provenance = pca.provenance.unwrap();
291
292 assert!(provenance.cat.kid.starts_with("https://"));
293 assert!(provenance.executor.kid.starts_with("spiffe://"));
294 }
295
296 #[test]
297 fn test_json_roundtrip() {
298 let pca = sample_pca_n();
299 let json = pca.to_json().unwrap();
300 let decoded = PcaPayload::from_json(&json).unwrap();
301 assert_eq!(pca, decoded);
302 }
303
304 #[test]
305 fn test_cbor_smaller_than_json() {
306 let pca = sample_pca_n();
307 let cbor = pca.to_cbor().unwrap();
308 let json = pca.to_json().unwrap();
309
310 println!("CBOR: {} bytes", cbor.len());
311 println!("JSON: {} bytes", json.len());
312
313 assert!(cbor.len() < json.len());
314 }
315
316 #[test]
317 fn test_monotonicity_ops_reduced() {
318 let pca_0 = sample_pca_0();
319 let pca_n = sample_pca_n();
320
321 assert_eq!(pca_0.ops.len(), 2);
322 assert_eq!(pca_n.ops.len(), 1);
323 assert_eq!(pca_0.p_0, pca_n.p_0);
324 }
325
326 #[test]
327 fn test_allows_ops() {
328 let pca = sample_pca_0();
329
330 assert!(pca.allows_ops(&["read:/user/*".into()]));
331 assert!(pca.allows_ops(&["read:/user/*".into(), "write:/user/*".into()]));
332 assert!(!pca.allows_ops(&["read:/sys/*".into()]));
333 }
334
335 #[test]
336 fn test_minimal_executor_binding() {
337 let binding = ExecutorBinding::new().with("org", "simple-org");
338
339 let pca = PcaPayload {
340 hop: 1,
341 p_0: "https://idp.example.com/users/alice".into(),
342 ops: vec!["read:/user/*".into()],
343 executor: Executor { binding },
344 provenance: None,
345 constraints: None,
346 };
347
348 let cbor = pca.to_cbor().unwrap();
349 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
350
351 assert_eq!(decoded.executor.binding.get_str("org"), Some("simple-org"));
352 }
353
354 #[test]
355 fn test_executor_binding_flexible() {
356 let binding = ExecutorBinding::new()
357 .with("org", "acme-corp")
358 .with("region", "eu-west-1")
359 .with("env", "prod");
360
361 let pca = PcaPayload {
362 hop: 0,
363 p_0: "https://idp.example.com/users/alice".into(),
364 ops: vec!["invoke:*".into()],
365 executor: Executor { binding },
366 provenance: None,
367 constraints: None,
368 };
369
370 let cbor = pca.to_cbor().unwrap();
371 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
372
373 assert_eq!(decoded.executor.binding.get_str("org"), Some("acme-corp"));
374 assert_eq!(
375 decoded.executor.binding.get_str("region"),
376 Some("eu-west-1")
377 );
378 }
379
380 #[test]
381 fn test_nested_binding() {
382 let k8s = DynamicMap::new()
383 .with("cluster", "prod-eu")
384 .with("namespace", "default");
385
386 let binding = ExecutorBinding::new()
387 .with("org", "acme-corp")
388 .with_map("kubernetes", k8s)
389 .with_array("regions", vec!["eu-west-1", "eu-west-2"]);
390
391 let pca = PcaPayload {
392 hop: 0,
393 p_0: "https://idp.example.com/users/alice".into(),
394 ops: vec!["read:*".into()],
395 executor: Executor { binding },
396 provenance: None,
397 constraints: None,
398 };
399
400 let cbor = pca.to_cbor().unwrap();
401 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
402
403 let k8s_decoded = decoded.executor.binding.get_map("kubernetes").unwrap();
404 assert_eq!(k8s_decoded.get_str("cluster"), Some("prod-eu"));
405 assert_eq!(k8s_decoded.get_str("namespace"), Some("default"));
406 }
407
408 #[test]
409 fn test_binding_with_json_value() {
410 let binding = ExecutorBinding::new().with("org", "acme-corp").with_value(
411 "metadata",
412 serde_json::json!({
413 "version": "1.2.3",
414 "replicas": 3,
415 "labels": {
416 "app": "gateway",
417 "tier": "frontend"
418 }
419 }),
420 );
421
422 let pca = PcaPayload {
423 hop: 0,
424 p_0: "https://idp.example.com/users/alice".into(),
425 ops: vec!["read:*".into()],
426 executor: Executor { binding },
427 provenance: None,
428 constraints: None,
429 };
430
431 let cbor = pca.to_cbor().unwrap();
432 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
433
434 let metadata = decoded.executor.binding.get("metadata").unwrap();
435 assert_eq!(metadata["version"], "1.2.3");
436 assert_eq!(metadata["replicas"], 3);
437 }
438}