Skip to main content

astrid_core/groups/
mod.rs

1//! Static group-to-capability configuration (issue #670).
2//!
3//! A [`GroupConfig`] names a small set of built-in groups
4//! ([`BUILTIN_ADMIN`], [`BUILTIN_AGENT`], [`BUILTIN_RESTRICTED`]) and
5//! optionally merges operator-defined custom groups from
6//! `$ASTRID_HOME/etc/groups.toml`. Each group confers a set of capability
7//! patterns, evaluated left-to-right against the colon-delimited grammar
8//! in [`crate::capability_grammar`].
9//!
10//! # Design contract
11//!
12//! - Built-in groups are baked in. Attempting to redefine them in
13//!   `groups.toml` is a hard error at load time.
14//! - Custom groups go through [`validate_capability`] for every entry.
15//! - The universal `*` pattern is reserved for the built-in `admin`
16//!   group. Custom groups may grant it only by explicitly opting in via
17//!   `unsafe_admin = true` on that group; otherwise it's rejected at load.
18//! - Missing `groups.toml` → built-ins only (the single-tenant default).
19//! - Malformed TOML, unknown fields, or duplicate group names are hard
20//!   errors — this fails the kernel boot, which is intentional.
21//! - `GroupConfig::get` returning `None` for a name referenced by a
22//!   principal profile is **not** an error here; the caller
23//!   ([`CapabilityCheck`](../../../astrid-capabilities/src/policy.rs))
24//!   treats it as fail-closed and logs a `warn!`.
25
26mod 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
39/// Canonical name of the built-in administrator group.
40pub const BUILTIN_ADMIN: &str = "admin";
41/// Canonical name of the built-in agent group (self-scoped capabilities).
42pub const BUILTIN_AGENT: &str = "agent";
43/// Canonical name of the built-in restricted group (no capabilities).
44pub const BUILTIN_RESTRICTED: &str = "restricted";
45
46const BUILTIN_NAMES: [&str; 3] = [BUILTIN_ADMIN, BUILTIN_AGENT, BUILTIN_RESTRICTED];
47
48/// Errors raised when loading or validating a [`GroupConfig`].
49#[derive(Debug, Error)]
50pub enum GroupConfigError {
51    /// Filesystem IO failed while reading `groups.toml`.
52    #[error("groups config io error: {0}")]
53    Io(#[from] io::Error),
54    /// `groups.toml` failed to parse as TOML, or contains unknown fields.
55    #[error("groups config parse error: {0}")]
56    Parse(#[from] toml::de::Error),
57    /// A group entry attempts to redefine a reserved built-in group name.
58    #[error("built-in group {name:?} may not be redefined in groups.toml")]
59    RedefinedBuiltin {
60        /// Name of the built-in group the config tried to overwrite.
61        name: String,
62    },
63    /// Two different group entries share the same name.
64    ///
65    /// Note: the TOML parser itself rejects duplicate keys in a single
66    /// table, but this variant covers the future case where multiple
67    /// sources are merged.
68    #[error("groups config declares {name:?} more than once")]
69    DuplicateName {
70        /// Duplicated group name.
71        name: String,
72    },
73    /// A group entry contains a capability that fails the grammar
74    /// validator.
75    #[error("groups config: group {group:?} capability {cap:?} rejected: {reason}")]
76    InvalidCapability {
77        /// Name of the offending group.
78        group: String,
79        /// Raw capability string that failed validation.
80        cap: String,
81        /// Underlying grammar error.
82        reason: CapabilityGrammarError,
83    },
84    /// A custom group grants the universal `*` capability without
85    /// opting in to the `unsafe_admin` flag.
86    #[error(
87        "groups config: custom group {group:?} grants '*' (universal admin); \
88         set `unsafe_admin = true` to confirm this elevation"
89    )]
90    UnsafeUniversalGrant {
91        /// Name of the offending custom group.
92        group: String,
93    },
94    /// A runtime admin mutation targets a group name that does not
95    /// exist in the current config. Returned by
96    /// [`GroupConfig::modify_custom_group`] and
97    /// [`GroupConfig::remove_group`].
98    #[error("groups config: unknown group {name:?}")]
99    UnknownGroup {
100        /// Name of the group that could not be located.
101        name: String,
102    },
103}
104
105/// Result alias for [`GroupConfig`] operations.
106pub type GroupConfigResult<T> = Result<T, GroupConfigError>;
107
108/// A named set of capability patterns.
109///
110/// Custom groups can opt-in to granting the universal `*` capability by
111/// setting [`Group::unsafe_admin`] — intended as a safeguard against
112/// typo-driven privilege escalation.
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct Group {
116    /// Capability patterns this group confers. Each pattern is validated
117    /// by [`validate_capability`] at load time.
118    #[serde(default)]
119    pub capabilities: Vec<String>,
120    /// Human-readable description surfaced in CLI and audit log views.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub description: Option<String>,
123    /// Opt-in flag: custom groups must set this to grant the universal
124    /// `*` capability, making the elevation deliberate and visible in
125    /// the config.
126    #[serde(default)]
127    pub unsafe_admin: bool,
128}
129
130/// The frozen group-to-capability map consumed by
131/// [`CapabilityCheck`](../../../astrid-capabilities/src/policy.rs).
132///
133/// Built at kernel boot from built-ins merged with any operator-provided
134/// `groups.toml`. Treat the resulting value as immutable — hot reload is
135/// deferred to Layer 6.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct GroupConfig {
138    /// Group name → group definition.
139    pub groups: HashMap<String, Group>,
140}
141
142/// TOML wrapper: the on-disk representation uses a top-level `[groups.*]`
143/// table so operators write `[groups.ops]` rather than a nested
144/// `[[groups]]` array.
145#[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    /// Canonical on-disk path for the system-wide groups config.
154    #[must_use]
155    pub fn path_for(home: &AstridHome) -> PathBuf {
156        home.etc_dir().join("groups.toml")
157    }
158
159    /// Return a [`GroupConfig`] containing only the built-in groups.
160    #[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    /// Load the group config from `home`'s `etc/groups.toml`, falling
170    /// back to [`Self::builtin_only`] if the file is absent.
171    ///
172    /// # Errors
173    ///
174    /// See [`GroupConfigError`].
175    pub fn load(home: &AstridHome) -> GroupConfigResult<Self> {
176        Self::load_from_path(&Self::path_for(home))
177    }
178
179    /// Load the group config from an explicit path.
180    ///
181    /// # Errors
182    ///
183    /// See [`GroupConfigError`].
184    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    /// Parse a [`GroupConfig`] from raw TOML, merging with the built-ins.
196    ///
197    /// # Errors
198    ///
199    /// See [`GroupConfigError`].
200    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        // Reject redefinition of any built-in group.
207        for name in custom.keys() {
208            if is_builtin(name) {
209                return Err(GroupConfigError::RedefinedBuiltin { name: name.clone() });
210            }
211        }
212
213        // Validate each custom group's capability entries.
214        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    /// Look up a group by name, if present.
234    #[must_use]
235    pub fn get(&self, name: &str) -> Option<&Group> {
236        self.groups.get(name)
237    }
238
239    /// Number of groups in the resolved config (built-ins + custom).
240    #[must_use]
241    pub fn len(&self) -> usize {
242        self.groups.len()
243    }
244
245    /// Whether the config contains no groups. Always `false` in practice
246    /// because built-ins are baked in.
247    #[must_use]
248    pub fn is_empty(&self) -> bool {
249        self.groups.is_empty()
250    }
251
252    /// Iterator over `(group_name, &Group)`.
253    pub fn iter(&self) -> impl Iterator<Item = (&String, &Group)> {
254        self.groups.iter()
255    }
256
257    /// Return `true` if `name` refers to one of the reserved built-in
258    /// groups ([`BUILTIN_ADMIN`], [`BUILTIN_AGENT`], [`BUILTIN_RESTRICTED`]).
259    #[must_use]
260    pub fn is_builtin_name(name: &str) -> bool {
261        is_builtin(name)
262    }
263
264    /// Return a new [`GroupConfig`] with a custom group inserted.
265    ///
266    /// Validates the group with the same rules the boot loader applies
267    /// to `groups.toml`: built-in names are rejected, every capability
268    /// passes [`validate_capability`], and the universal `*` pattern
269    /// requires `unsafe_admin = true`.
270    ///
271    /// # Errors
272    ///
273    /// - [`GroupConfigError::RedefinedBuiltin`] if `name` is a built-in.
274    /// - [`GroupConfigError::DuplicateName`] if `name` already exists in
275    ///   the custom set (an existing custom group must be removed or
276    ///   modified, not re-inserted).
277    /// - [`GroupConfigError::InvalidCapability`] on a bad capability
278    ///   string.
279    /// - [`GroupConfigError::UnsafeUniversalGrant`] if `group.capabilities`
280    ///   contains `*` without `unsafe_admin = true`.
281    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    /// Return a new [`GroupConfig`] with a partial update applied to a
296    /// custom group. Any field left as `None` is preserved.
297    ///
298    /// # Errors
299    ///
300    /// - [`GroupConfigError::RedefinedBuiltin`] if `name` is a built-in.
301    /// - [`GroupConfigError::DuplicateName`] if `name` is unknown — modify
302    ///   is strictly an update, not an upsert.
303    /// - [`GroupConfigError::InvalidCapability`] /
304    ///   [`GroupConfigError::UnsafeUniversalGrant`] from revalidation.
305    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    /// Return a new [`GroupConfig`] with `name` removed.
341    ///
342    /// Built-in groups cannot be removed and produce
343    /// [`GroupConfigError::RedefinedBuiltin`]. Removing an unknown custom
344    /// group produces [`GroupConfigError::DuplicateName`] (reused as the
345    /// "not a custom group I know about" sentinel).
346    ///
347    /// # Errors
348    ///
349    /// See above.
350    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                // `admin` is the one group the universal `*` is reserved for;
385                // the `unsafe_admin` flag is a custom-group concern.
386                unsafe_admin: false,
387            },
388        ),
389        (
390            BUILTIN_AGENT,
391            Group {
392                // `self:*` already subsumes self:quota:get / self:agent:list,
393                // but they are listed explicitly so operators reading the
394                // built-ins can see that agents have self-service visibility
395                // into their own quota and agent row (issue #672 Layer 6).
396                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        // Agent gets self:* plus explicit self-service visibility caps
456        // (self:quota:get, self:agent:list) added in Layer 6 / issue #672.
457        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        // Built-ins remain intact.
492        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    // ── Runtime mutation helpers (issue #672) ─────────────────────────
624
625    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        // Original untouched (returned by value, immutable).
641        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()]), // bad grammar
743                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        // Built-ins survive.
759        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}