ai_memory/federation/identity/
inventory.rs1use std::collections::BTreeSet;
36use std::path::Path;
37
38use serde::Deserialize;
39
40pub const FED_INVENTORY_PATH_ENV: &str = "AI_MEMORY_FED_INVENTORY_PATH";
43
44pub const MIN_QUORUM_WIDTH: u32 = 1;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
51#[serde(rename_all = "kebab-case", deny_unknown_fields)]
52pub enum AttestorMethod {
53 MtlsCert,
57 NodePlugin,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct NodeSpec {
67 pub id: String,
70 pub attestor: AttestorMethod,
72 pub cred_ttl: String,
75 pub renew_before: String,
78 #[serde(default)]
81 pub roles: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct RegionSpec {
88 pub name: String,
90 #[serde(default)]
94 pub intermediate_ca: Option<String>,
95 #[serde(default)]
97 pub nodes: Vec<NodeSpec>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct QuorumSpec {
104 pub width: u32,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct EnforcementSpec {
112 #[serde(default)]
116 pub require_sig: bool,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct FederationInventory {
124 pub trust_domain: String,
126 #[serde(default)]
129 pub root_ca: Option<String>,
130 #[serde(default)]
132 pub regions: Vec<RegionSpec>,
133 pub quorum: QuorumSpec,
135 #[serde(default)]
137 pub enforcement: EnforcementSpec,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum InventoryError {
143 Parse(String),
145 Io(String),
147 EmptyTrustDomain,
149 EmptyRegionName,
151 InvalidNodeId { id: String, reason: String },
153 InvalidDuration {
156 node: String,
157 field: &'static str,
158 value: String,
159 },
160 RenewBeforeNotShorterThanTtl { node: String },
164 DuplicateNodeId { id: String },
166 InvalidQuorumWidth { width: u32 },
168}
169
170impl InventoryError {
171 #[must_use]
173 pub fn tag(&self) -> &'static str {
174 match self {
175 Self::Parse(_) => "inventory_parse_error",
176 Self::Io(_) => "inventory_io_error",
177 Self::EmptyTrustDomain => "inventory_empty_trust_domain",
178 Self::EmptyRegionName => "inventory_empty_region_name",
179 Self::InvalidNodeId { .. } => "inventory_invalid_node_id",
180 Self::InvalidDuration { .. } => "inventory_invalid_duration",
181 Self::RenewBeforeNotShorterThanTtl { .. } => "inventory_renew_before_not_shorter",
182 Self::DuplicateNodeId { .. } => "inventory_duplicate_node_id",
183 Self::InvalidQuorumWidth { .. } => "inventory_invalid_quorum_width",
184 }
185 }
186}
187
188impl std::fmt::Display for InventoryError {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 match self {
191 Self::Parse(msg) | Self::Io(msg) => write!(f, "{} ({msg})", self.tag()),
192 Self::InvalidNodeId { id, reason } => {
193 write!(f, "{} (id={id}: {reason})", self.tag())
194 }
195 Self::InvalidDuration { node, field, value } => {
196 write!(f, "{} (node={node} {field}={value})", self.tag())
197 }
198 Self::RenewBeforeNotShorterThanTtl { node } => {
199 write!(f, "{} (node={node})", self.tag())
200 }
201 Self::DuplicateNodeId { id } => write!(f, "{} (id={id})", self.tag()),
202 Self::InvalidQuorumWidth { width } => write!(f, "{} (width={width})", self.tag()),
203 _ => f.write_str(self.tag()),
204 }
205 }
206}
207
208impl std::error::Error for InventoryError {}
209
210impl FederationInventory {
211 pub fn from_yaml_str(yaml: &str) -> Result<Self, InventoryError> {
217 let inventory: Self =
218 serde_yaml::from_str(yaml).map_err(|e| InventoryError::Parse(e.to_string()))?;
219 inventory.validate()?;
220 Ok(inventory)
221 }
222
223 pub fn load_from_path(path: &Path) -> Result<Self, InventoryError> {
229 let raw = std::fs::read_to_string(path).map_err(|e| InventoryError::Io(e.to_string()))?;
230 Self::from_yaml_str(&raw)
231 }
232
233 pub fn load_from_env() -> Result<Option<Self>, InventoryError> {
242 match std::env::var(FED_INVENTORY_PATH_ENV) {
243 Ok(path) => Self::load_from_path(Path::new(&path)).map(Some),
244 Err(_) => Ok(None),
245 }
246 }
247
248 pub fn nodes(&self) -> impl Iterator<Item = &NodeSpec> {
250 self.regions.iter().flat_map(|r| r.nodes.iter())
251 }
252
253 pub fn validate(&self) -> Result<(), InventoryError> {
261 if self.trust_domain.trim().is_empty() {
262 return Err(InventoryError::EmptyTrustDomain);
263 }
264 if self.quorum.width < MIN_QUORUM_WIDTH {
265 return Err(InventoryError::InvalidQuorumWidth {
266 width: self.quorum.width,
267 });
268 }
269 let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
270 for region in &self.regions {
271 if region.name.trim().is_empty() {
272 return Err(InventoryError::EmptyRegionName);
273 }
274 for node in ®ion.nodes {
275 node.validate()?;
276 if !seen_ids.insert(node.id.as_str()) {
277 return Err(InventoryError::DuplicateNodeId {
278 id: node.id.clone(),
279 });
280 }
281 }
282 }
283 Ok(())
284 }
285}
286
287impl NodeSpec {
288 #[must_use]
290 pub fn cred_ttl_duration(&self) -> Option<chrono::Duration> {
291 crate::config::parse_duration_string(&self.cred_ttl)
292 }
293
294 #[must_use]
296 pub fn renew_before_duration(&self) -> Option<chrono::Duration> {
297 crate::config::parse_duration_string(&self.renew_before)
298 }
299
300 fn validate(&self) -> Result<(), InventoryError> {
302 crate::validate::validate_agent_id_shape(&self.id).map_err(|e| {
303 InventoryError::InvalidNodeId {
304 id: self.id.clone(),
305 reason: e.to_string(),
306 }
307 })?;
308 let ttl = positive_duration(self.cred_ttl_duration()).ok_or_else(|| {
309 InventoryError::InvalidDuration {
310 node: self.id.clone(),
311 field: "cred_ttl",
312 value: self.cred_ttl.clone(),
313 }
314 })?;
315 let renew = positive_duration(self.renew_before_duration()).ok_or_else(|| {
316 InventoryError::InvalidDuration {
317 node: self.id.clone(),
318 field: "renew_before",
319 value: self.renew_before.clone(),
320 }
321 })?;
322 if renew >= ttl {
323 return Err(InventoryError::RenewBeforeNotShorterThanTtl {
324 node: self.id.clone(),
325 });
326 }
327 Ok(())
328 }
329}
330
331fn positive_duration(d: Option<chrono::Duration>) -> Option<chrono::Duration> {
334 d.filter(|d| *d > chrono::Duration::zero())
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 const SAMPLE: &str = "\
342trust_domain: fleet.example
343root_ca: root.pub
344regions:
345 - name: nyc
346 intermediate_ca: nyc-int.pub
347 nodes:
348 - id: region/nyc/node-1
349 attestor: mtls-cert
350 cred_ttl: 1h
351 renew_before: 15m
352 roles: [writer, reader]
353 - id: region/nyc/node-2
354 attestor: node-plugin
355 cred_ttl: 2h
356 renew_before: 30m
357 - name: sfo
358 nodes:
359 - id: region/sfo/node-1
360 attestor: mtls-cert
361 cred_ttl: 1h
362 renew_before: 10m
363quorum:
364 width: 2
365enforcement:
366 require_sig: true
367";
368
369 #[test]
370 fn parses_and_validates_a_full_inventory() {
371 let inv = FederationInventory::from_yaml_str(SAMPLE).expect("valid");
372 assert_eq!(inv.trust_domain, "fleet.example");
373 assert_eq!(inv.root_ca.as_deref(), Some("root.pub"));
374 assert_eq!(inv.regions.len(), 2);
375 assert_eq!(inv.quorum.width, 2);
376 assert!(inv.enforcement.require_sig);
377 assert_eq!(inv.nodes().count(), 3);
378 let first = inv.nodes().next().expect("a node");
379 assert_eq!(first.id, "region/nyc/node-1");
380 assert_eq!(first.attestor, AttestorMethod::MtlsCert);
381 assert_eq!(first.roles, vec!["writer", "reader"]);
382 assert_eq!(first.cred_ttl_duration(), Some(chrono::Duration::hours(1)));
383 assert_eq!(
384 first.renew_before_duration(),
385 Some(chrono::Duration::minutes(15))
386 );
387 }
388
389 #[test]
390 fn enforcement_defaults_to_permissive_when_omitted() {
391 let yaml = "\
392trust_domain: d
393quorum:
394 width: 1
395";
396 let inv = FederationInventory::from_yaml_str(yaml).expect("valid");
397 assert!(!inv.enforcement.require_sig);
398 assert_eq!(inv.nodes().count(), 0);
399 }
400
401 #[test]
402 fn unknown_field_is_a_hard_parse_error() {
403 let yaml = "\
404trust_domain: d
405quorum:
406 width: 1
407enforcement:
408 requir_sig: true
409";
410 let err = FederationInventory::from_yaml_str(yaml).expect_err("typo must fail");
411 assert_eq!(err.tag(), "inventory_parse_error");
412 }
413
414 #[test]
415 fn empty_trust_domain_is_rejected() {
416 let yaml = "\
417trust_domain: ' '
418quorum:
419 width: 1
420";
421 let err = FederationInventory::from_yaml_str(yaml).expect_err("empty domain");
422 assert_eq!(err, InventoryError::EmptyTrustDomain);
423 }
424
425 #[test]
426 fn zero_quorum_width_is_rejected() {
427 let yaml = "\
428trust_domain: d
429quorum:
430 width: 0
431";
432 let err = FederationInventory::from_yaml_str(yaml).expect_err("zero width");
433 assert_eq!(err, InventoryError::InvalidQuorumWidth { width: 0 });
434 }
435
436 #[test]
437 fn path_traversal_node_id_is_rejected() {
438 let yaml = "\
439trust_domain: d
440regions:
441 - name: r
442 nodes:
443 - id: ../../etc/secret
444 attestor: mtls-cert
445 cred_ttl: 1h
446 renew_before: 5m
447quorum:
448 width: 1
449";
450 let err = FederationInventory::from_yaml_str(yaml).expect_err("traversal");
451 assert_eq!(err.tag(), "inventory_invalid_node_id");
452 }
453
454 #[test]
455 fn unparsable_duration_is_rejected() {
456 let yaml = "\
457trust_domain: d
458regions:
459 - name: r
460 nodes:
461 - id: node-1
462 attestor: mtls-cert
463 cred_ttl: soon
464 renew_before: 5m
465quorum:
466 width: 1
467";
468 let err = FederationInventory::from_yaml_str(yaml).expect_err("bad ttl");
469 assert_eq!(
470 err,
471 InventoryError::InvalidDuration {
472 node: "node-1".to_string(),
473 field: "cred_ttl",
474 value: "soon".to_string(),
475 }
476 );
477 }
478
479 #[test]
480 fn renew_before_not_shorter_than_ttl_is_rejected() {
481 let yaml = "\
482trust_domain: d
483regions:
484 - name: r
485 nodes:
486 - id: node-1
487 attestor: mtls-cert
488 cred_ttl: 1h
489 renew_before: 1h
490quorum:
491 width: 1
492";
493 let err = FederationInventory::from_yaml_str(yaml).expect_err("renew>=ttl");
494 assert_eq!(
495 err,
496 InventoryError::RenewBeforeNotShorterThanTtl {
497 node: "node-1".to_string()
498 }
499 );
500 }
501
502 #[test]
503 fn duplicate_node_id_across_regions_is_rejected() {
504 let yaml = "\
505trust_domain: d
506regions:
507 - name: r1
508 nodes:
509 - id: dup
510 attestor: mtls-cert
511 cred_ttl: 1h
512 renew_before: 5m
513 - name: r2
514 nodes:
515 - id: dup
516 attestor: mtls-cert
517 cred_ttl: 1h
518 renew_before: 5m
519quorum:
520 width: 1
521";
522 let err = FederationInventory::from_yaml_str(yaml).expect_err("dup id");
523 assert_eq!(
524 err,
525 InventoryError::DuplicateNodeId {
526 id: "dup".to_string()
527 }
528 );
529 }
530
531 #[test]
532 fn empty_region_name_is_rejected() {
533 let yaml = "\
534trust_domain: d
535regions:
536 - name: ' '
537 nodes: []
538quorum:
539 width: 1
540";
541 let err = FederationInventory::from_yaml_str(yaml).expect_err("empty region");
542 assert_eq!(err, InventoryError::EmptyRegionName);
543 }
544
545 #[test]
546 fn load_from_env_unset_is_none() {
547 unsafe { std::env::remove_var(FED_INVENTORY_PATH_ENV) };
549 assert_eq!(FederationInventory::load_from_env().expect("ok"), None);
550 }
551}