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