1mod io_impl;
27
28use std::collections::{HashMap, HashSet};
29use std::fs;
30use std::io;
31use std::path::{Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34use thiserror::Error;
35
36use crate::capability_grammar::{CapabilityGrammarError, validate_capability};
37use crate::dirs::AstridHome;
38
39pub const BUILTIN_ADMIN: &str = "admin";
41pub const BUILTIN_AGENT: &str = "agent";
43pub const BUILTIN_RESTRICTED: &str = "restricted";
45
46const BUILTIN_NAMES: [&str; 3] = [BUILTIN_ADMIN, BUILTIN_AGENT, BUILTIN_RESTRICTED];
47
48#[derive(Debug, Error)]
50pub enum GroupConfigError {
51 #[error("groups config io error: {0}")]
53 Io(#[from] io::Error),
54 #[error("groups config parse error: {0}")]
56 Parse(#[from] toml::de::Error),
57 #[error("built-in group {name:?} may not be redefined in groups.toml")]
59 RedefinedBuiltin {
60 name: String,
62 },
63 #[error("groups config declares {name:?} more than once")]
69 DuplicateName {
70 name: String,
72 },
73 #[error("groups config: group {group:?} capability {cap:?} rejected: {reason}")]
76 InvalidCapability {
77 group: String,
79 cap: String,
81 reason: CapabilityGrammarError,
83 },
84 #[error(
87 "groups config: custom group {group:?} grants '*' (universal admin); \
88 set `unsafe_admin = true` to confirm this elevation"
89 )]
90 UnsafeUniversalGrant {
91 group: String,
93 },
94 #[error("groups config: unknown group {name:?}")]
99 UnknownGroup {
100 name: String,
102 },
103}
104
105pub type GroupConfigResult<T> = Result<T, GroupConfigError>;
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct Group {
116 #[serde(default)]
119 pub capabilities: Vec<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub description: Option<String>,
123 #[serde(default)]
127 pub unsafe_admin: bool,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct GroupConfig {
138 pub groups: HashMap<String, Group>,
140}
141
142#[derive(Debug, Clone, Default, Deserialize)]
146#[serde(deny_unknown_fields)]
147struct GroupsFile {
148 #[serde(default)]
149 groups: HashMap<String, Group>,
150}
151
152impl GroupConfig {
153 #[must_use]
155 pub fn path_for(home: &AstridHome) -> PathBuf {
156 home.etc_dir().join("groups.toml")
157 }
158
159 #[must_use]
161 pub fn builtin_only() -> Self {
162 let mut groups = HashMap::with_capacity(BUILTIN_NAMES.len());
163 for (name, group) in builtin_entries() {
164 groups.insert(name.to_string(), group);
165 }
166 Self { groups }
167 }
168
169 pub fn load(home: &AstridHome) -> GroupConfigResult<Self> {
176 Self::load_from_path(&Self::path_for(home))
177 }
178
179 pub fn load_from_path(path: &Path) -> GroupConfigResult<Self> {
185 let contents = match fs::read_to_string(path) {
186 Ok(c) => c,
187 Err(e) if e.kind() == io::ErrorKind::NotFound => {
188 return Ok(Self::builtin_only());
189 },
190 Err(e) => return Err(GroupConfigError::Io(e)),
191 };
192 Self::from_toml_str(&contents)
193 }
194
195 pub fn from_toml_str(contents: &str) -> GroupConfigResult<Self> {
201 let file: GroupsFile = toml::from_str(contents)?;
202 Self::from_custom_groups(file.groups)
203 }
204
205 fn from_custom_groups(custom: HashMap<String, Group>) -> GroupConfigResult<Self> {
206 for name in custom.keys() {
208 if is_builtin(name) {
209 return Err(GroupConfigError::RedefinedBuiltin { name: name.clone() });
210 }
211 }
212
213 let mut seen: HashSet<&str> = HashSet::new();
215 for (name, group) in &custom {
216 if !seen.insert(name.as_str()) {
217 return Err(GroupConfigError::DuplicateName { name: name.clone() });
218 }
219 validate_custom_group(name, group)?;
220 }
221
222 let mut groups = HashMap::with_capacity(BUILTIN_NAMES.len().saturating_add(custom.len()));
223 for (name, group) in builtin_entries() {
224 groups.insert(name.to_string(), group);
225 }
226 for (name, group) in custom {
227 groups.insert(name, group);
228 }
229
230 Ok(Self { groups })
231 }
232
233 #[must_use]
235 pub fn get(&self, name: &str) -> Option<&Group> {
236 self.groups.get(name)
237 }
238
239 #[must_use]
241 pub fn len(&self) -> usize {
242 self.groups.len()
243 }
244
245 #[must_use]
248 pub fn is_empty(&self) -> bool {
249 self.groups.is_empty()
250 }
251
252 pub fn iter(&self) -> impl Iterator<Item = (&String, &Group)> {
254 self.groups.iter()
255 }
256
257 #[must_use]
260 pub fn is_builtin_name(name: &str) -> bool {
261 is_builtin(name)
262 }
263
264 pub fn insert_custom_group(&self, name: String, group: Group) -> GroupConfigResult<Self> {
282 if is_builtin(&name) {
283 return Err(GroupConfigError::RedefinedBuiltin { name });
284 }
285 if self.groups.contains_key(&name) {
286 return Err(GroupConfigError::DuplicateName { name });
287 }
288 validate_custom_group(&name, &group)?;
289
290 let mut next = self.groups.clone();
291 next.insert(name, group);
292 Ok(Self { groups: next })
293 }
294
295 pub fn modify_custom_group(
306 &self,
307 name: &str,
308 capabilities: Option<Vec<String>>,
309 description: Option<Option<String>>,
310 unsafe_admin: Option<bool>,
311 ) -> GroupConfigResult<Self> {
312 if is_builtin(name) {
313 return Err(GroupConfigError::RedefinedBuiltin {
314 name: name.to_string(),
315 });
316 }
317 let existing = self
318 .groups
319 .get(name)
320 .ok_or_else(|| GroupConfigError::UnknownGroup {
321 name: name.to_string(),
322 })?;
323 let mut updated = existing.clone();
324 if let Some(caps) = capabilities {
325 updated.capabilities = caps;
326 }
327 if let Some(desc) = description {
328 updated.description = desc;
329 }
330 if let Some(flag) = unsafe_admin {
331 updated.unsafe_admin = flag;
332 }
333 validate_custom_group(name, &updated)?;
334
335 let mut next = self.groups.clone();
336 next.insert(name.to_string(), updated);
337 Ok(Self { groups: next })
338 }
339
340 pub fn remove_group(&self, name: &str) -> GroupConfigResult<Self> {
351 if is_builtin(name) {
352 return Err(GroupConfigError::RedefinedBuiltin {
353 name: name.to_string(),
354 });
355 }
356 if !self.groups.contains_key(name) {
357 return Err(GroupConfigError::UnknownGroup {
358 name: name.to_string(),
359 });
360 }
361 let mut next = self.groups.clone();
362 next.remove(name);
363 Ok(Self { groups: next })
364 }
365}
366
367impl Default for GroupConfig {
368 fn default() -> Self {
369 Self::builtin_only()
370 }
371}
372
373fn is_builtin(name: &str) -> bool {
374 BUILTIN_NAMES.contains(&name)
375}
376
377fn builtin_entries() -> [(&'static str, Group); 3] {
378 [
379 (
380 BUILTIN_ADMIN,
381 Group {
382 capabilities: vec!["*".to_string()],
383 description: Some("Built-in administrator — universal capability grant".into()),
384 unsafe_admin: false,
387 },
388 ),
389 (
390 BUILTIN_AGENT,
391 Group {
392 capabilities: vec![
397 "self:*".to_string(),
398 "self:quota:get".to_string(),
399 "self:agent:list".to_string(),
400 "delegate:self:*".to_string(),
401 ],
402 description: Some(
403 "Built-in agent — self-scoped capability grants for routine agent workflows"
404 .into(),
405 ),
406 unsafe_admin: false,
407 },
408 ),
409 (
410 BUILTIN_RESTRICTED,
411 Group {
412 capabilities: Vec::new(),
413 description: Some(
414 "Built-in restricted — no implicit capabilities; grants must be explicit"
415 .into(),
416 ),
417 unsafe_admin: false,
418 },
419 ),
420 ]
421}
422
423fn validate_custom_group(name: &str, group: &Group) -> GroupConfigResult<()> {
424 for cap in &group.capabilities {
425 if let Err(reason) = validate_capability(cap) {
426 return Err(GroupConfigError::InvalidCapability {
427 group: name.to_string(),
428 cap: cap.clone(),
429 reason,
430 });
431 }
432 if cap == "*" && !group.unsafe_admin {
433 return Err(GroupConfigError::UnsafeUniversalGrant {
434 group: name.to_string(),
435 });
436 }
437 }
438 Ok(())
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 use tempfile::tempdir;
446
447 #[test]
448 fn builtin_only_contains_admin_agent_restricted() {
449 let cfg = GroupConfig::builtin_only();
450 assert_eq!(cfg.len(), 3);
451 assert_eq!(
452 cfg.get(BUILTIN_ADMIN).unwrap().capabilities,
453 vec!["*".to_string()]
454 );
455 let agent_caps = &cfg.get(BUILTIN_AGENT).unwrap().capabilities;
458 assert!(agent_caps.contains(&"self:*".to_string()));
459 assert!(agent_caps.contains(&"self:quota:get".to_string()));
460 assert!(agent_caps.contains(&"self:agent:list".to_string()));
461 assert!(agent_caps.contains(&"delegate:self:*".to_string()));
462 assert!(cfg.get(BUILTIN_RESTRICTED).unwrap().capabilities.is_empty());
463 }
464
465 #[test]
466 fn load_missing_file_returns_builtins() {
467 let dir = tempdir().unwrap();
468 let home = AstridHome::from_path(dir.path());
469 assert!(!GroupConfig::path_for(&home).exists());
470 let cfg = GroupConfig::load(&home).unwrap();
471 assert_eq!(cfg.len(), 3);
472 }
473
474 #[test]
475 fn load_merges_custom_groups_with_builtins() {
476 let toml_doc = r#"
477 [groups.ops]
478 description = "Deployment operators"
479 capabilities = ["capsule:install", "capsule:remove"]
480
481 [groups.auditor]
482 capabilities = ["audit:read", "agent:list"]
483 "#;
484 let cfg = GroupConfig::from_toml_str(toml_doc).unwrap();
485 assert_eq!(cfg.len(), 5);
486 assert_eq!(
487 cfg.get("ops").unwrap().capabilities,
488 vec!["capsule:install".to_string(), "capsule:remove".to_string()]
489 );
490 assert_eq!(cfg.get("auditor").unwrap().capabilities.len(), 2);
491 assert_eq!(
493 cfg.get(BUILTIN_ADMIN).unwrap().capabilities,
494 vec!["*".to_string()]
495 );
496 }
497
498 #[test]
499 fn rejects_redefined_builtin() {
500 let toml_doc = r#"
501 [groups.admin]
502 capabilities = ["custom:garbage"]
503 "#;
504 let err = GroupConfig::from_toml_str(toml_doc).unwrap_err();
505 match err {
506 GroupConfigError::RedefinedBuiltin { name } => assert_eq!(name, BUILTIN_ADMIN),
507 other => panic!("expected RedefinedBuiltin, got: {other:?}"),
508 }
509 }
510
511 #[test]
512 fn rejects_redefined_agent_builtin() {
513 let toml_doc = r#"
514 [groups.agent]
515 capabilities = ["self:capsule:install"]
516 "#;
517 assert!(matches!(
518 GroupConfig::from_toml_str(toml_doc),
519 Err(GroupConfigError::RedefinedBuiltin { .. })
520 ));
521 }
522
523 #[test]
524 fn rejects_unknown_top_level_field() {
525 let toml_doc = r#"
526 typo_field = true
527 [groups.ops]
528 capabilities = ["capsule:install"]
529 "#;
530 assert!(matches!(
531 GroupConfig::from_toml_str(toml_doc),
532 Err(GroupConfigError::Parse(_))
533 ));
534 }
535
536 #[test]
537 fn rejects_unknown_group_field() {
538 let toml_doc = "
539 [groups.ops]
540 priviledges = []
541 ";
542 assert!(matches!(
543 GroupConfig::from_toml_str(toml_doc),
544 Err(GroupConfigError::Parse(_))
545 ));
546 }
547
548 #[test]
549 fn rejects_invalid_capability_grammar() {
550 let toml_doc = r#"
551 [groups.ops]
552 capabilities = ["system:shut down"]
553 "#;
554 let err = GroupConfig::from_toml_str(toml_doc).unwrap_err();
555 match err {
556 GroupConfigError::InvalidCapability { group, cap, .. } => {
557 assert_eq!(group, "ops");
558 assert_eq!(cap, "system:shut down");
559 },
560 other => panic!("expected InvalidCapability, got: {other:?}"),
561 }
562 }
563
564 #[test]
565 fn rejects_custom_group_with_universal_star_without_opt_in() {
566 let toml_doc = r#"
567 [groups.privileged]
568 capabilities = ["*"]
569 "#;
570 let err = GroupConfig::from_toml_str(toml_doc).unwrap_err();
571 assert!(matches!(err, GroupConfigError::UnsafeUniversalGrant { .. }));
572 }
573
574 #[test]
575 fn accepts_custom_group_with_universal_star_and_opt_in() {
576 let toml_doc = r#"
577 [groups.privileged]
578 unsafe_admin = true
579 capabilities = ["*"]
580 "#;
581 let cfg = GroupConfig::from_toml_str(toml_doc).unwrap();
582 assert_eq!(
583 cfg.get("privileged").unwrap().capabilities,
584 vec!["*".to_string()]
585 );
586 assert!(cfg.get("privileged").unwrap().unsafe_admin);
587 }
588
589 #[test]
590 fn rejects_double_glob_capability() {
591 let toml_doc = r#"
592 [groups.ops]
593 capabilities = ["capsule:**"]
594 "#;
595 let err = GroupConfig::from_toml_str(toml_doc).unwrap_err();
596 match err {
597 GroupConfigError::InvalidCapability { reason, .. } => {
598 assert_eq!(reason, CapabilityGrammarError::DoubleStar);
599 },
600 other => panic!("expected InvalidCapability(DoubleStar), got: {other:?}"),
601 }
602 }
603
604 #[test]
605 fn load_from_path_parses_file() {
606 let dir = tempdir().unwrap();
607 let path = dir.path().join("groups.toml");
608 fs::write(
609 &path,
610 "[groups.ops]\ncapabilities = [\"capsule:install\"]\n",
611 )
612 .unwrap();
613 let cfg = GroupConfig::load_from_path(&path).unwrap();
614 assert!(cfg.get("ops").is_some());
615 }
616
617 #[test]
618 fn get_returns_none_for_unknown_name() {
619 let cfg = GroupConfig::builtin_only();
620 assert!(cfg.get("not-a-real-group").is_none());
621 }
622
623 fn custom(caps: &[&str]) -> Group {
626 Group {
627 capabilities: caps.iter().map(|s| (*s).to_string()).collect(),
628 description: None,
629 unsafe_admin: false,
630 }
631 }
632
633 #[test]
634 fn insert_custom_group_adds_and_validates() {
635 let cfg = GroupConfig::builtin_only();
636 let next = cfg
637 .insert_custom_group("ops".to_string(), custom(&["capsule:install"]))
638 .unwrap();
639 assert!(next.get("ops").is_some());
640 assert!(cfg.get("ops").is_none());
642 }
643
644 #[test]
645 fn insert_custom_group_rejects_builtin_name() {
646 let cfg = GroupConfig::builtin_only();
647 let err = cfg
648 .insert_custom_group(BUILTIN_ADMIN.to_string(), custom(&["system:shutdown"]))
649 .unwrap_err();
650 assert!(matches!(err, GroupConfigError::RedefinedBuiltin { .. }));
651 }
652
653 #[test]
654 fn insert_custom_group_rejects_duplicate_name() {
655 let cfg = GroupConfig::builtin_only()
656 .insert_custom_group("ops".to_string(), custom(&["capsule:install"]))
657 .unwrap();
658 let err = cfg
659 .insert_custom_group("ops".to_string(), custom(&["audit:read"]))
660 .unwrap_err();
661 assert!(matches!(err, GroupConfigError::DuplicateName { .. }));
662 }
663
664 #[test]
665 fn insert_custom_group_rejects_unsafe_star_without_opt_in() {
666 let err = GroupConfig::builtin_only()
667 .insert_custom_group("privileged".to_string(), custom(&["*"]))
668 .unwrap_err();
669 assert!(matches!(err, GroupConfigError::UnsafeUniversalGrant { .. }));
670 }
671
672 #[test]
673 fn insert_custom_group_rejects_invalid_capability_grammar() {
674 let err = GroupConfig::builtin_only()
675 .insert_custom_group("bad".to_string(), custom(&["system:shut down"]))
676 .unwrap_err();
677 assert!(matches!(err, GroupConfigError::InvalidCapability { .. }));
678 }
679
680 #[test]
681 fn modify_custom_group_updates_capabilities() {
682 let cfg = GroupConfig::builtin_only()
683 .insert_custom_group("ops".to_string(), custom(&["capsule:install"]))
684 .unwrap();
685 let next = cfg
686 .modify_custom_group(
687 "ops",
688 Some(vec!["capsule:install".into(), "capsule:remove".into()]),
689 None,
690 None,
691 )
692 .unwrap();
693 assert_eq!(next.get("ops").unwrap().capabilities.len(), 2);
694 }
695
696 #[test]
697 fn modify_custom_group_partial_update_preserves_other_fields() {
698 let cfg = GroupConfig::builtin_only()
699 .insert_custom_group(
700 "ops".to_string(),
701 Group {
702 capabilities: vec!["capsule:install".into()],
703 description: Some("original".into()),
704 unsafe_admin: false,
705 },
706 )
707 .unwrap();
708 let next = cfg
709 .modify_custom_group("ops", None, Some(Some("updated".into())), None)
710 .unwrap();
711 let g = next.get("ops").unwrap();
712 assert_eq!(g.description.as_deref(), Some("updated"));
713 assert_eq!(g.capabilities, vec!["capsule:install".to_string()]);
714 }
715
716 #[test]
717 fn modify_custom_group_rejects_builtin() {
718 let cfg = GroupConfig::builtin_only();
719 let err = cfg
720 .modify_custom_group(BUILTIN_ADMIN, Some(vec!["audit:read".into()]), None, None)
721 .unwrap_err();
722 assert!(matches!(err, GroupConfigError::RedefinedBuiltin { .. }));
723 }
724
725 #[test]
726 fn modify_custom_group_rejects_unknown_name() {
727 let cfg = GroupConfig::builtin_only();
728 let err = cfg
729 .modify_custom_group("never-defined", Some(vec![]), None, None)
730 .unwrap_err();
731 assert!(matches!(err, GroupConfigError::UnknownGroup { .. }));
732 }
733
734 #[test]
735 fn modify_custom_group_revalidates_new_capabilities() {
736 let cfg = GroupConfig::builtin_only()
737 .insert_custom_group("ops".to_string(), custom(&["capsule:install"]))
738 .unwrap();
739 let err = cfg
740 .modify_custom_group(
741 "ops",
742 Some(vec!["system:shut down".into()]), None,
744 None,
745 )
746 .unwrap_err();
747 assert!(matches!(err, GroupConfigError::InvalidCapability { .. }));
748 }
749
750 #[test]
751 fn remove_group_drops_custom_group() {
752 let cfg = GroupConfig::builtin_only()
753 .insert_custom_group("ops".to_string(), custom(&["capsule:install"]))
754 .unwrap();
755 assert!(cfg.get("ops").is_some());
756 let next = cfg.remove_group("ops").unwrap();
757 assert!(next.get("ops").is_none());
758 assert!(next.get(BUILTIN_ADMIN).is_some());
760 }
761
762 #[test]
763 fn remove_group_rejects_every_builtin() {
764 let cfg = GroupConfig::builtin_only();
765 for name in [BUILTIN_ADMIN, BUILTIN_AGENT, BUILTIN_RESTRICTED] {
766 let err = cfg.remove_group(name).unwrap_err();
767 assert!(
768 matches!(err, GroupConfigError::RedefinedBuiltin { .. }),
769 "expected RedefinedBuiltin for {name}, got {err:?}"
770 );
771 }
772 }
773
774 #[test]
775 fn remove_group_rejects_unknown_name() {
776 let cfg = GroupConfig::builtin_only();
777 let err = cfg.remove_group("never-defined").unwrap_err();
778 assert!(matches!(err, GroupConfigError::UnknownGroup { .. }));
779 }
780
781 #[test]
782 fn is_builtin_name_covers_every_builtin() {
783 assert!(GroupConfig::is_builtin_name(BUILTIN_ADMIN));
784 assert!(GroupConfig::is_builtin_name(BUILTIN_AGENT));
785 assert!(GroupConfig::is_builtin_name(BUILTIN_RESTRICTED));
786 assert!(!GroupConfig::is_builtin_name("ops"));
787 }
788}