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>, ciborium::ser::Error<std::io::Error>> {
180 let mut buf = Vec::new();
181 ciborium::into_writer(self, &mut buf)?;
182 Ok(buf)
183 }
184
185 pub fn from_cbor(bytes: &[u8]) -> Result<Self, ciborium::de::Error<std::io::Error>> {
187 ciborium::from_reader(bytes)
188 }
189
190 pub fn to_json(&self) -> Result<String, serde_json::Error> {
192 serde_json::to_string(self)
193 }
194
195 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
197 serde_json::to_string_pretty(self)
198 }
199
200 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
202 serde_json::from_str(json)
203 }
204
205 pub fn is_origin(&self) -> bool {
207 self.hop == 0
208 }
209
210 pub fn allows_ops(&self, requested_ops: &[String]) -> bool {
212 requested_ops.iter().all(|op| self.ops.contains(op))
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn sample_pca_0() -> PcaPayload {
221 let binding = ExecutorBinding::new().with("org", "acme-corp");
222
223 PcaPayload {
224 hop: 0,
225 p_0: "https://idp.example.com/users/alice".into(),
226 ops: vec!["read:/user/*".into(), "write:/user/*".into()],
227 executor: Executor { binding },
228 provenance: None,
229 constraints: Some(Constraints {
230 temporal: Some(TemporalConstraints {
231 iat: Some("2025-12-11T10:00:00Z".into()),
232 exp: Some("2025-12-11T11:00:00Z".into()),
233 nbf: None,
234 }),
235 }),
236 }
237 }
238
239 fn sample_pca_n() -> PcaPayload {
240 let binding = ExecutorBinding::new().with("org", "acme-corp");
241
242 PcaPayload {
243 hop: 2,
244 p_0: "https://idp.example.com/users/alice".into(),
245 ops: vec!["read:/user/*".into()],
246 executor: Executor { binding },
247 provenance: Some(Provenance {
248 cat: CatProvenance {
249 kid: "https://cat.acme-corp.com/keys/1".into(),
250 signature: vec![0u8; 64],
251 },
252 executor: ExecutorProvenance {
253 kid: "spiffe://acme-corp/ns/prod/sa/archive".into(),
254 signature: vec![0u8; 64],
255 },
256 }),
257 constraints: Some(Constraints {
258 temporal: Some(TemporalConstraints {
259 iat: Some("2025-12-11T10:00:00Z".into()),
260 exp: Some("2025-12-11T10:30:00Z".into()),
261 nbf: None,
262 }),
263 }),
264 }
265 }
266
267 #[test]
268 fn test_pca_0_cbor_roundtrip() {
269 let pca = sample_pca_0();
270 let cbor = pca.to_cbor().unwrap();
271 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
272 assert_eq!(pca, decoded);
273 assert_eq!(decoded.hop, 0);
274 assert!(decoded.provenance.is_none());
275 assert!(decoded.is_origin());
276 }
277
278 #[test]
279 fn test_pca_n_cbor_roundtrip() {
280 let pca = sample_pca_n();
281 let cbor = pca.to_cbor().unwrap();
282 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
283 assert_eq!(pca, decoded);
284 assert_eq!(decoded.hop, 2);
285 assert!(decoded.provenance.is_some());
286 assert!(!decoded.is_origin());
287 }
288
289 #[test]
290 fn test_provenance_uses_kid() {
291 let pca = sample_pca_n();
292 let provenance = pca.provenance.unwrap();
293
294 assert!(provenance.cat.kid.starts_with("https://"));
295 assert!(provenance.executor.kid.starts_with("spiffe://"));
296 }
297
298 #[test]
299 fn test_json_roundtrip() {
300 let pca = sample_pca_n();
301 let json = pca.to_json().unwrap();
302 let decoded = PcaPayload::from_json(&json).unwrap();
303 assert_eq!(pca, decoded);
304 }
305
306 #[test]
307 fn test_cbor_smaller_than_json() {
308 let pca = sample_pca_n();
309 let cbor = pca.to_cbor().unwrap();
310 let json = pca.to_json().unwrap();
311
312 println!("CBOR: {} bytes", cbor.len());
313 println!("JSON: {} bytes", json.len());
314
315 assert!(cbor.len() < json.len());
316 }
317
318 #[test]
319 fn test_monotonicity_ops_reduced() {
320 let pca_0 = sample_pca_0();
321 let pca_n = sample_pca_n();
322
323 assert_eq!(pca_0.ops.len(), 2);
324 assert_eq!(pca_n.ops.len(), 1);
325 assert_eq!(pca_0.p_0, pca_n.p_0);
326 }
327
328 #[test]
329 fn test_allows_ops() {
330 let pca = sample_pca_0();
331
332 assert!(pca.allows_ops(&["read:/user/*".into()]));
333 assert!(pca.allows_ops(&["read:/user/*".into(), "write:/user/*".into()]));
334 assert!(!pca.allows_ops(&["read:/sys/*".into()]));
335 }
336
337 #[test]
338 fn test_minimal_executor_binding() {
339 let binding = ExecutorBinding::new().with("org", "simple-org");
340
341 let pca = PcaPayload {
342 hop: 1,
343 p_0: "https://idp.example.com/users/alice".into(),
344 ops: vec!["read:/user/*".into()],
345 executor: Executor { binding },
346 provenance: None,
347 constraints: None,
348 };
349
350 let cbor = pca.to_cbor().unwrap();
351 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
352
353 assert_eq!(decoded.executor.binding.get_str("org"), Some("simple-org"));
354 }
355
356 #[test]
357 fn test_executor_binding_flexible() {
358 let binding = ExecutorBinding::new()
359 .with("org", "acme-corp")
360 .with("region", "eu-west-1")
361 .with("env", "prod");
362
363 let pca = PcaPayload {
364 hop: 0,
365 p_0: "https://idp.example.com/users/alice".into(),
366 ops: vec!["invoke:*".into()],
367 executor: Executor { binding },
368 provenance: None,
369 constraints: None,
370 };
371
372 let cbor = pca.to_cbor().unwrap();
373 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
374
375 assert_eq!(decoded.executor.binding.get_str("org"), Some("acme-corp"));
376 assert_eq!(
377 decoded.executor.binding.get_str("region"),
378 Some("eu-west-1")
379 );
380 }
381
382 #[test]
383 fn test_nested_binding() {
384 let k8s = DynamicMap::new()
385 .with("cluster", "prod-eu")
386 .with("namespace", "default");
387
388 let binding = ExecutorBinding::new()
389 .with("org", "acme-corp")
390 .with_map("kubernetes", k8s)
391 .with_array("regions", vec!["eu-west-1", "eu-west-2"]);
392
393 let pca = PcaPayload {
394 hop: 0,
395 p_0: "https://idp.example.com/users/alice".into(),
396 ops: vec!["read:*".into()],
397 executor: Executor { binding },
398 provenance: None,
399 constraints: None,
400 };
401
402 let cbor = pca.to_cbor().unwrap();
403 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
404
405 let k8s_decoded = decoded.executor.binding.get_map("kubernetes").unwrap();
406 assert_eq!(k8s_decoded.get_str("cluster"), Some("prod-eu"));
407 assert_eq!(k8s_decoded.get_str("namespace"), Some("default"));
408 }
409
410 #[test]
411 fn test_binding_with_json_value() {
412 let binding = ExecutorBinding::new().with("org", "acme-corp").with_value(
413 "metadata",
414 serde_json::json!({
415 "version": "1.2.3",
416 "replicas": 3,
417 "labels": {
418 "app": "gateway",
419 "tier": "frontend"
420 }
421 }),
422 );
423
424 let pca = PcaPayload {
425 hop: 0,
426 p_0: "https://idp.example.com/users/alice".into(),
427 ops: vec!["read:*".into()],
428 executor: Executor { binding },
429 provenance: None,
430 constraints: None,
431 };
432
433 let cbor = pca.to_cbor().unwrap();
434 let decoded = PcaPayload::from_cbor(&cbor).unwrap();
435
436 let metadata = decoded.executor.binding.get("metadata").unwrap();
437 assert_eq!(metadata["version"], "1.2.3");
438 assert_eq!(metadata["replicas"], 3);
439 }
440}