Skip to main content

meerkat_mobkit/access/
model.rs

1//! Access-control configuration schema and validation.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7/// View an agent in any console surface: sidebar, roster, topology,
8/// timeline frames, identity status, inspection, and event streams.
9pub const ACTION_AGENT_VIEW: &str = "agent.view";
10/// Send a message to an agent (console send / chat).
11pub const ACTION_AGENT_SEND: &str = "agent.send";
12/// Create new members: ensure/spawn/fork helpers, run flows.
13pub const ACTION_AGENT_SPAWN: &str = "agent.spawn";
14/// Respawn an existing agent.
15pub const ACTION_AGENT_RESPAWN: &str = "agent.respawn";
16/// Retire / force-cancel / delete an agent.
17pub const ACTION_AGENT_RETIRE: &str = "agent.retire";
18/// Reset an agent's durable state.
19pub const ACTION_AGENT_RESET: &str = "agent.reset";
20/// Read gating queues and audit history.
21pub const ACTION_GATING_VIEW: &str = "gating.view";
22/// Decide pending gating approvals.
23pub const ACTION_GATING_DECIDE: &str = "gating.decide";
24/// Subscribe to whole-mob event surfaces (raw mob/structural event streams).
25pub const ACTION_MOB_OBSERVE: &str = "mob.observe";
26/// Operate runtime plumbing: routing tables, labels, wiring, reconcile.
27pub const ACTION_RUNTIME_ADMIN: &str = "runtime.admin";
28/// Author mobpacks in the Flow Editor: drafts, authoring operations,
29/// validation, source rendering, export/import, and authoring catalogs.
30pub const ACTION_MOBPACK_AUTHOR: &str = "mobpack.author";
31/// Execute a mobpack deploy on the host (`rkat mob run`).
32pub const ACTION_MOBPACK_DEPLOY: &str = "mobpack.deploy";
33/// Read and mutate the access-control configuration itself.
34pub const ACTION_ACCESS_ADMIN: &str = "access.admin";
35
36/// The full action vocabulary, in display order.
37pub const ACCESS_ACTIONS: &[&str] = &[
38    ACTION_AGENT_VIEW,
39    ACTION_AGENT_SEND,
40    ACTION_AGENT_SPAWN,
41    ACTION_AGENT_RESPAWN,
42    ACTION_AGENT_RETIRE,
43    ACTION_AGENT_RESET,
44    ACTION_GATING_VIEW,
45    ACTION_GATING_DECIDE,
46    ACTION_MOB_OBSERVE,
47    ACTION_RUNTIME_ADMIN,
48    ACTION_MOBPACK_AUTHOR,
49    ACTION_MOBPACK_DEPLOY,
50    ACTION_ACCESS_ADMIN,
51];
52
53/// Root access-control configuration. Serializable as TOML
54/// (`config/access.toml`) and JSON (RPC admin surface).
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56pub struct AccessControlConfig {
57    /// Master switch. When `false` the controller is a transparent no-op
58    /// and every surface behaves exactly as if no access control existed.
59    #[serde(default)]
60    pub enabled: bool,
61    /// Subjects with unconditional full access, including the right to
62    /// edit this configuration. Must be non-empty while `enabled` is true
63    /// so a bad rule set can never lock every administrator out.
64    #[serde(default)]
65    pub admins: Vec<String>,
66    /// Named groups of subjects. Group membership is the per-user live
67    /// configuration surface: assigning a subject to a group immediately
68    /// changes what every rule referencing that group grants them.
69    #[serde(default)]
70    pub groups: BTreeMap<String, AccessGroup>,
71    /// Attribute rules, evaluated as a set (order is irrelevant;
72    /// deny-overrides-allow).
73    #[serde(default)]
74    pub rules: Vec<AccessRule>,
75}
76
77/// A named set of subjects.
78#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub struct AccessGroup {
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82    #[serde(default)]
83    pub members: Vec<String>,
84}
85
86/// Allow or deny.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum AccessEffect {
90    #[default]
91    Allow,
92    Deny,
93}
94
95/// One attribute rule.
96///
97/// Dimension semantics:
98/// - `subjects` / `groups`: the rule applies to a principal when its
99///   subject is listed in `subjects` (or `subjects` contains `"*"`), or it
100///   belongs to any listed group. When both lists are empty the rule
101///   applies to every principal, including unauthenticated ones.
102/// - `actions`: required, non-empty. Entries are exact action names,
103///   `"prefix.*"` wildcards, or `"*"`.
104/// - `agents` / `roles` / `match_labels`: resource selectors. Each
105///   specified selector must match (logical AND across dimensions); within
106///   `agents` and `roles` any listed value matches (logical OR), and
107///   `"*"` matches every value. Empty selectors leave that dimension
108///   unconstrained. A rule with all three
109///   empty matches every resource, including action checks that have no
110///   resource at all (e.g. `gating.decide`).
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112pub struct AccessRule {
113    pub id: String,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub description: Option<String>,
116    #[serde(default)]
117    pub effect: AccessEffect,
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub subjects: Vec<String>,
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub groups: Vec<String>,
122    pub actions: Vec<String>,
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub agents: Vec<String>,
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub roles: Vec<String>,
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub match_labels: BTreeMap<String, String>,
129}
130
131impl AccessRule {
132    /// True when the rule constrains the resource in any way.
133    pub fn has_resource_selector(&self) -> bool {
134        !self.agents.is_empty() || !self.roles.is_empty() || !self.match_labels.is_empty()
135    }
136}
137
138/// Validation failure for an [`AccessControlConfig`].
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum AccessConfigError {
141    EnabledWithoutAdmins,
142    EmptyRuleId,
143    DuplicateRuleId(String),
144    EmptyActions(String),
145    UnknownAction { rule: String, action: String },
146    UnknownGroup { rule: String, group: String },
147    UnknownRule(String),
148    Parse(String),
149    Io(String),
150}
151
152impl std::fmt::Display for AccessConfigError {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::EnabledWithoutAdmins => write!(
156                f,
157                "access control cannot be enabled without at least one admin subject"
158            ),
159            Self::EmptyRuleId => write!(f, "access rule id must not be empty"),
160            Self::DuplicateRuleId(id) => write!(f, "duplicate access rule id: {id}"),
161            Self::EmptyActions(id) => write!(f, "access rule {id}: actions must not be empty"),
162            Self::UnknownAction { rule, action } => {
163                write!(f, "access rule {rule}: unknown action {action:?}")
164            }
165            Self::UnknownGroup { rule, group } => {
166                write!(f, "access rule {rule}: unknown group {group:?}")
167            }
168            Self::UnknownRule(id) => write!(f, "unknown access rule id: {id}"),
169            Self::Parse(message) => write!(f, "access config could not be parsed: {message}"),
170            Self::Io(message) => write!(f, "access config io error: {message}"),
171        }
172    }
173}
174
175impl std::error::Error for AccessConfigError {}
176
177fn action_pattern_is_known(pattern: &str) -> bool {
178    if pattern == "*" {
179        return true;
180    }
181    if let Some(prefix) = pattern.strip_suffix(".*") {
182        return ACCESS_ACTIONS.iter().any(|action| {
183            action
184                .rsplit_once('.')
185                .is_some_and(|(action_prefix, _)| action_prefix == prefix)
186        });
187    }
188    ACCESS_ACTIONS.contains(&pattern)
189}
190
191/// Validate a configuration before accepting it.
192///
193/// Enforces the anti-lockout invariant (enabled implies admins), unique
194/// non-empty rule ids, a non-empty known action list per rule, and that
195/// every referenced group is defined.
196pub fn validate_access_config(config: &AccessControlConfig) -> Result<(), AccessConfigError> {
197    if config.enabled && config.admins.iter().all(|admin| admin.trim().is_empty()) {
198        return Err(AccessConfigError::EnabledWithoutAdmins);
199    }
200    let mut seen_ids = std::collections::BTreeSet::new();
201    for rule in &config.rules {
202        if rule.id.trim().is_empty() {
203            return Err(AccessConfigError::EmptyRuleId);
204        }
205        if !seen_ids.insert(rule.id.as_str()) {
206            return Err(AccessConfigError::DuplicateRuleId(rule.id.clone()));
207        }
208        if rule.actions.is_empty() {
209            return Err(AccessConfigError::EmptyActions(rule.id.clone()));
210        }
211        for action in &rule.actions {
212            if !action_pattern_is_known(action) {
213                return Err(AccessConfigError::UnknownAction {
214                    rule: rule.id.clone(),
215                    action: action.clone(),
216                });
217            }
218        }
219        for group in &rule.groups {
220            if !config.groups.contains_key(group) {
221                return Err(AccessConfigError::UnknownGroup {
222                    rule: rule.id.clone(),
223                    group: group.clone(),
224                });
225            }
226        }
227    }
228    Ok(())
229}
230
231#[cfg(test)]
232#[allow(clippy::expect_used, clippy::unwrap_used)]
233mod tests {
234    use super::*;
235
236    fn rule(id: &str, actions: &[&str]) -> AccessRule {
237        AccessRule {
238            id: id.to_string(),
239            actions: actions.iter().map(ToString::to_string).collect(),
240            ..AccessRule::default()
241        }
242    }
243
244    #[test]
245    fn default_config_is_disabled_and_valid() {
246        let config = AccessControlConfig::default();
247        assert!(!config.enabled);
248        assert!(validate_access_config(&config).is_ok());
249    }
250
251    #[test]
252    fn enabling_requires_admins() {
253        let config = AccessControlConfig {
254            enabled: true,
255            ..AccessControlConfig::default()
256        };
257        assert_eq!(
258            validate_access_config(&config),
259            Err(AccessConfigError::EnabledWithoutAdmins)
260        );
261    }
262
263    #[test]
264    fn rules_require_known_actions() {
265        let mut config = AccessControlConfig {
266            admins: vec!["root@example.test".to_string()],
267            rules: vec![rule("r1", &["agent.view", "agent.*", "*"])],
268            ..AccessControlConfig::default()
269        };
270        assert!(validate_access_config(&config).is_ok());
271        config.rules.push(rule("r2", &["agent.fly"]));
272        assert!(matches!(
273            validate_access_config(&config),
274            Err(AccessConfigError::UnknownAction { .. })
275        ));
276    }
277
278    #[test]
279    fn rules_reject_duplicate_ids_and_unknown_groups() {
280        let mut config = AccessControlConfig {
281            rules: vec![rule("r1", &["agent.view"]), rule("r1", &["agent.send"])],
282            ..AccessControlConfig::default()
283        };
284        assert_eq!(
285            validate_access_config(&config),
286            Err(AccessConfigError::DuplicateRuleId("r1".to_string()))
287        );
288        config.rules.pop();
289        config.rules[0].groups = vec!["ops".to_string()];
290        assert!(matches!(
291            validate_access_config(&config),
292            Err(AccessConfigError::UnknownGroup { .. })
293        ));
294    }
295
296    #[test]
297    fn config_round_trips_through_toml() {
298        let config = AccessControlConfig {
299            enabled: true,
300            admins: vec!["root@example.test".to_string()],
301            groups: BTreeMap::from([(
302                "ops".to_string(),
303                AccessGroup {
304                    description: Some("Operations".to_string()),
305                    members: vec!["alice@example.test".to_string()],
306                },
307            )]),
308            rules: vec![AccessRule {
309                id: "ops-see-all".to_string(),
310                description: Some("ops see everything".to_string()),
311                effect: AccessEffect::Allow,
312                groups: vec!["ops".to_string()],
313                actions: vec!["agent.view".to_string()],
314                ..AccessRule::default()
315            }],
316        };
317        let toml = toml::to_string_pretty(&config).expect("serialize");
318        let parsed: AccessControlConfig = toml::from_str(&toml).expect("parse");
319        assert_eq!(parsed, config);
320    }
321}