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/// Write durable identity-scoped memory records for future agent context.
13pub const ACTION_AGENT_MEMORY_WRITE: &str = "agent.memory.write";
14/// Delete durable identity-scoped memory records.
15pub const ACTION_AGENT_MEMORY_DELETE: &str = "agent.memory.delete";
16/// Read identity-scoped memory: the recall/manifest RPCs and every console
17/// Memory-panel read (§10.3). Realm-scoped reads ride an *unscoped*
18/// `agent.memory.read` grant (a rule with no resource selector).
19pub const ACTION_AGENT_MEMORY_READ: &str = "agent.memory.read";
20/// Read operator-scoped memory records (§10.3): cross-mob personal facts
21/// about the operator, more sensitive than any other scope. Never implied
22/// by an unscoped `agent.memory.read` grant and never granted by the
23/// migration compat rewrite — always an explicit rule.
24pub const ACTION_OPERATOR_MEMORY_READ: &str = "operator.memory.read";
25/// Administrative memory operations on an identity's store (imports,
26/// re-keying, floor overrides). Reserved: no console RPC maps to it yet.
27pub const ACTION_AGENT_MEMORY_ADMIN: &str = "agent.memory.admin";
28/// Read mob-scoped memory records in the console Memory panel.
29pub const ACTION_MOB_MEMORY_READ: &str = "mob.memory.read";
30/// Propose a record into mob scope (`propose` surfaces).
31pub const ACTION_MOB_MEMORY_PROPOSE: &str = "mob.memory.propose";
32/// Commit records directly into mob scope. Reserved for a future direct
33/// commit RPC — steward promotions ride the gating flow (`gating.decide`),
34/// not this action, so nothing maps to it yet.
35pub const ACTION_MOB_MEMORY_COMMIT: &str = "mob.memory.commit";
36/// Read the quarantine queue and its verdict surfaces (§10.3).
37pub const ACTION_MEMORY_QUARANTINE_REVIEW: &str = "memory.quarantine.review";
38/// Create new members: ensure/spawn/fork helpers, run flows.
39pub const ACTION_AGENT_SPAWN: &str = "agent.spawn";
40/// Respawn an existing agent.
41pub const ACTION_AGENT_RESPAWN: &str = "agent.respawn";
42/// Retire / force-cancel / delete an agent.
43pub const ACTION_AGENT_RETIRE: &str = "agent.retire";
44/// Reset an agent's durable state.
45pub const ACTION_AGENT_RESET: &str = "agent.reset";
46/// Read gating queues and audit history.
47pub const ACTION_GATING_VIEW: &str = "gating.view";
48/// Decide pending gating approvals.
49pub const ACTION_GATING_DECIDE: &str = "gating.decide";
50/// Subscribe to whole-mob event surfaces (raw mob/structural event streams).
51pub const ACTION_MOB_OBSERVE: &str = "mob.observe";
52/// Operate runtime plumbing: routing tables, labels, wiring, reconcile.
53pub const ACTION_RUNTIME_ADMIN: &str = "runtime.admin";
54/// Author mobpacks in the Flow Editor: drafts, authoring operations,
55/// validation, source rendering, export/import, and authoring catalogs.
56pub const ACTION_MOBPACK_AUTHOR: &str = "mobpack.author";
57/// Execute a mobpack deploy on the host (`rkat mob run`).
58pub const ACTION_MOBPACK_DEPLOY: &str = "mobpack.deploy";
59/// Read and mutate the access-control configuration itself.
60pub const ACTION_ACCESS_ADMIN: &str = "access.admin";
61
62/// The full action vocabulary, in display order.
63pub const ACCESS_ACTIONS: &[&str] = &[
64    ACTION_AGENT_VIEW,
65    ACTION_AGENT_SEND,
66    ACTION_AGENT_MEMORY_WRITE,
67    ACTION_AGENT_MEMORY_DELETE,
68    ACTION_AGENT_MEMORY_READ,
69    ACTION_AGENT_MEMORY_ADMIN,
70    ACTION_OPERATOR_MEMORY_READ,
71    ACTION_MOB_MEMORY_READ,
72    ACTION_MOB_MEMORY_PROPOSE,
73    ACTION_MOB_MEMORY_COMMIT,
74    ACTION_MEMORY_QUARANTINE_REVIEW,
75    ACTION_AGENT_SPAWN,
76    ACTION_AGENT_RESPAWN,
77    ACTION_AGENT_RETIRE,
78    ACTION_AGENT_RESET,
79    ACTION_GATING_VIEW,
80    ACTION_GATING_DECIDE,
81    ACTION_MOB_OBSERVE,
82    ACTION_RUNTIME_ADMIN,
83    ACTION_MOBPACK_AUTHOR,
84    ACTION_MOBPACK_DEPLOY,
85    ACTION_ACCESS_ADMIN,
86];
87
88/// Root access-control configuration. Serializable as TOML
89/// (`config/access.toml`) and JSON (RPC admin surface).
90#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
91pub struct AccessControlConfig {
92    /// Master switch. When `false` the controller is a transparent no-op
93    /// and every surface behaves exactly as if no access control existed.
94    #[serde(default)]
95    pub enabled: bool,
96    /// Subjects with unconditional full access, including the right to
97    /// edit this configuration. Must be non-empty while `enabled` is true
98    /// so a bad rule set can never lock every administrator out.
99    #[serde(default)]
100    pub admins: Vec<String>,
101    /// Named groups of subjects. Group membership is the per-user live
102    /// configuration surface: assigning a subject to a group immediately
103    /// changes what every rule referencing that group grants them.
104    #[serde(default)]
105    pub groups: BTreeMap<String, AccessGroup>,
106    /// Attribute rules, evaluated as a set (order is irrelevant;
107    /// deny-overrides-allow).
108    #[serde(default)]
109    pub rules: Vec<AccessRule>,
110}
111
112/// A named set of subjects.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114pub struct AccessGroup {
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117    #[serde(default)]
118    pub members: Vec<String>,
119}
120
121/// Allow or deny.
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum AccessEffect {
125    #[default]
126    Allow,
127    Deny,
128}
129
130/// One attribute rule.
131///
132/// Dimension semantics:
133/// - `subjects` / `groups`: the rule applies to a principal when its
134///   subject is listed in `subjects` (or `subjects` contains `"*"`), or it
135///   belongs to any listed group. When both lists are empty the rule
136///   applies to every principal, including unauthenticated ones.
137/// - `actions`: required, non-empty. Entries are exact action names,
138///   `"prefix.*"` wildcards, or `"*"`.
139/// - `agents` / `roles` / `match_labels`: resource selectors. Each
140///   specified selector must match (logical AND across dimensions); within
141///   `agents` and `roles` any listed value matches (logical OR), and
142///   `"*"` matches every value. Empty selectors leave that dimension
143///   unconstrained. A rule with all three
144///   empty matches every resource, including action checks that have no
145///   resource at all (e.g. `gating.decide`).
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct AccessRule {
148    pub id: String,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub description: Option<String>,
151    #[serde(default)]
152    pub effect: AccessEffect,
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub subjects: Vec<String>,
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub groups: Vec<String>,
157    pub actions: Vec<String>,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub agents: Vec<String>,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub roles: Vec<String>,
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub match_labels: BTreeMap<String, String>,
164}
165
166impl AccessRule {
167    /// True when the rule constrains the resource in any way.
168    pub fn has_resource_selector(&self) -> bool {
169        !self.agents.is_empty() || !self.roles.is_empty() || !self.match_labels.is_empty()
170    }
171}
172
173/// Validation failure for an [`AccessControlConfig`].
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum AccessConfigError {
176    EnabledWithoutAdmins,
177    EmptyRuleId,
178    DuplicateRuleId(String),
179    EmptyActions(String),
180    UnknownAction { rule: String, action: String },
181    UnknownGroup { rule: String, group: String },
182    UnknownRule(String),
183    Parse(String),
184    Io(String),
185}
186
187impl std::fmt::Display for AccessConfigError {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            Self::EnabledWithoutAdmins => write!(
191                f,
192                "access control cannot be enabled without at least one admin subject"
193            ),
194            Self::EmptyRuleId => write!(f, "access rule id must not be empty"),
195            Self::DuplicateRuleId(id) => write!(f, "duplicate access rule id: {id}"),
196            Self::EmptyActions(id) => write!(f, "access rule {id}: actions must not be empty"),
197            Self::UnknownAction { rule, action } => {
198                write!(f, "access rule {rule}: unknown action {action:?}")
199            }
200            Self::UnknownGroup { rule, group } => {
201                write!(f, "access rule {rule}: unknown group {group:?}")
202            }
203            Self::UnknownRule(id) => write!(f, "unknown access rule id: {id}"),
204            Self::Parse(message) => write!(f, "access config could not be parsed: {message}"),
205            Self::Io(message) => write!(f, "access config io error: {message}"),
206        }
207    }
208}
209
210impl std::error::Error for AccessConfigError {}
211
212fn action_pattern_is_known(pattern: &str) -> bool {
213    if pattern == "*" {
214        return true;
215    }
216    if let Some(prefix) = pattern.strip_suffix(".*") {
217        return ACCESS_ACTIONS.iter().any(|action| {
218            action
219                .strip_prefix(prefix)
220                .is_some_and(|rest| rest.starts_with('.'))
221        });
222    }
223    ACCESS_ACTIONS.contains(&pattern)
224}
225
226/// Whether a rule action pattern (`*`, `prefix.*`, or an exact name)
227/// matches a concrete action. Single source of truth for pattern
228/// semantics, shared with rule evaluation.
229pub(crate) fn action_pattern_matches(pattern: &str, action: &str) -> bool {
230    if pattern == "*" || pattern == action {
231        return true;
232    }
233    pattern.strip_suffix(".*").is_some_and(|prefix| {
234        action
235            .strip_prefix(prefix)
236            .is_some_and(|rest| rest.starts_with('.'))
237    })
238}
239
240/// True when the pattern explicitly references the memory action family —
241/// one of its dot-separated segments (with a trailing `.*` stripped) is
242/// `memory`. Broad wildcards (`*`, `agent.*`) do NOT count: they already
243/// match the memory actions through ordinary pattern semantics and need no
244/// compat handling.
245fn pattern_mentions_memory(pattern: &str) -> bool {
246    pattern
247        .strip_suffix(".*")
248        .unwrap_or(pattern)
249        .split('.')
250        .any(|segment| segment == "memory")
251}
252
253/// §10.3 migration compat: configs written before the per-scope memory read
254/// actions existed keep working.
255///
256/// A config that mentions **no** memory action in any rule (neither the
257/// pre-existing `agent.memory.write`/`delete` nor any of the new read
258/// actions) is treated as memory-naive: every rule matching `agent.view`
259/// is extended to also cover `agent.memory.read`, for allow *and* deny
260/// rules alike, so "read rides view" exactly reproduces the pre-migration
261/// recall behavior (deny-overrides included). A config that mentions any
262/// memory action anywhere is taken literally and left untouched.
263///
264/// The extension is materialized into the rule list (and therefore into the
265/// persisted TOML on the next admin save), which also makes the rewrite
266/// self-limiting: a normalized config mentions `agent.memory.read` and is
267/// never rewritten again. Returns `true` when anything changed; callers log
268/// the recommendation to write explicit memory rules.
269pub fn normalize_access_config_for_memory_actions(config: &mut AccessControlConfig) -> bool {
270    let mentions_memory = config
271        .rules
272        .iter()
273        .flat_map(|rule| rule.actions.iter())
274        .any(|pattern| pattern_mentions_memory(pattern));
275    if mentions_memory {
276        return false;
277    }
278    let mut changed = false;
279    for rule in &mut config.rules {
280        let matches_view = rule
281            .actions
282            .iter()
283            .any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_VIEW));
284        let matches_read = rule
285            .actions
286            .iter()
287            .any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_MEMORY_READ));
288        if matches_view && !matches_read {
289            rule.actions.push(ACTION_AGENT_MEMORY_READ.to_string());
290            changed = true;
291        }
292    }
293    if changed {
294        tracing::warn!(
295            target: "mobkit::access",
296            "access config predates memory read actions; granting agent.memory.read wherever \
297             agent.view is granted (write explicit agent.memory.read / mob.memory.read / \
298             memory.quarantine.review rules to silence this)"
299        );
300    }
301    changed
302}
303
304/// Validate a configuration before accepting it.
305///
306/// Enforces the anti-lockout invariant (enabled implies admins), unique
307/// non-empty rule ids, a non-empty known action list per rule, and that
308/// every referenced group is defined.
309pub fn validate_access_config(config: &AccessControlConfig) -> Result<(), AccessConfigError> {
310    if config.enabled && config.admins.iter().all(|admin| admin.trim().is_empty()) {
311        return Err(AccessConfigError::EnabledWithoutAdmins);
312    }
313    let mut seen_ids = std::collections::BTreeSet::new();
314    for rule in &config.rules {
315        if rule.id.trim().is_empty() {
316            return Err(AccessConfigError::EmptyRuleId);
317        }
318        if !seen_ids.insert(rule.id.as_str()) {
319            return Err(AccessConfigError::DuplicateRuleId(rule.id.clone()));
320        }
321        if rule.actions.is_empty() {
322            return Err(AccessConfigError::EmptyActions(rule.id.clone()));
323        }
324        for action in &rule.actions {
325            if !action_pattern_is_known(action) {
326                return Err(AccessConfigError::UnknownAction {
327                    rule: rule.id.clone(),
328                    action: action.clone(),
329                });
330            }
331        }
332        for group in &rule.groups {
333            if !config.groups.contains_key(group) {
334                return Err(AccessConfigError::UnknownGroup {
335                    rule: rule.id.clone(),
336                    group: group.clone(),
337                });
338            }
339        }
340    }
341    Ok(())
342}
343
344#[cfg(test)]
345#[allow(clippy::expect_used, clippy::unwrap_used)]
346mod tests {
347    use super::*;
348
349    fn rule(id: &str, actions: &[&str]) -> AccessRule {
350        AccessRule {
351            id: id.to_string(),
352            actions: actions.iter().map(ToString::to_string).collect(),
353            ..AccessRule::default()
354        }
355    }
356
357    #[test]
358    fn default_config_is_disabled_and_valid() {
359        let config = AccessControlConfig::default();
360        assert!(!config.enabled);
361        assert!(validate_access_config(&config).is_ok());
362    }
363
364    #[test]
365    fn enabling_requires_admins() {
366        let config = AccessControlConfig {
367            enabled: true,
368            ..AccessControlConfig::default()
369        };
370        assert_eq!(
371            validate_access_config(&config),
372            Err(AccessConfigError::EnabledWithoutAdmins)
373        );
374    }
375
376    #[test]
377    fn rules_require_known_actions() {
378        let mut config = AccessControlConfig {
379            admins: vec!["root@example.test".to_string()],
380            rules: vec![rule(
381                "r1",
382                &["agent.view", "agent.memory.*", "agent.*", "*"],
383            )],
384            ..AccessControlConfig::default()
385        };
386        assert!(validate_access_config(&config).is_ok());
387        config.rules.push(rule("r2", &["agent.fly"]));
388        assert!(matches!(
389            validate_access_config(&config),
390            Err(AccessConfigError::UnknownAction { .. })
391        ));
392    }
393
394    #[test]
395    fn rules_reject_duplicate_ids_and_unknown_groups() {
396        let mut config = AccessControlConfig {
397            rules: vec![rule("r1", &["agent.view"]), rule("r1", &["agent.send"])],
398            ..AccessControlConfig::default()
399        };
400        assert_eq!(
401            validate_access_config(&config),
402            Err(AccessConfigError::DuplicateRuleId("r1".to_string()))
403        );
404        config.rules.pop();
405        config.rules[0].groups = vec!["ops".to_string()];
406        assert!(matches!(
407            validate_access_config(&config),
408            Err(AccessConfigError::UnknownGroup { .. })
409        ));
410    }
411
412    #[test]
413    fn memory_actions_validate() {
414        let config = AccessControlConfig {
415            admins: vec!["root@example.test".to_string()],
416            rules: vec![rule(
417                "r1",
418                &[
419                    "agent.memory.read",
420                    "agent.memory.admin",
421                    "operator.memory.read",
422                    "mob.memory.read",
423                    "mob.memory.propose",
424                    "mob.memory.commit",
425                    "memory.quarantine.review",
426                    "mob.memory.*",
427                    "memory.*",
428                ],
429            )],
430            ..AccessControlConfig::default()
431        };
432        assert!(validate_access_config(&config).is_ok());
433    }
434
435    #[test]
436    fn memory_naive_config_grants_read_alongside_view() {
437        // Pre-migration config: view granted broadly, view denied on one
438        // agent, an unrelated send rule. No memory action anywhere.
439        let mut deny_view = rule("deny-secret", &["agent.view"]);
440        deny_view.effect = AccessEffect::Deny;
441        deny_view.agents = vec!["identity:secret".to_string()];
442        let mut config = AccessControlConfig {
443            enabled: true,
444            admins: vec!["root@example.test".to_string()],
445            rules: vec![
446                rule("view-all", &["agent.view"]),
447                deny_view,
448                rule("send-one", &["agent.send"]),
449            ],
450            ..AccessControlConfig::default()
451        };
452        assert!(normalize_access_config_for_memory_actions(&mut config));
453        let actions_of = |id: &str| {
454            config
455                .rules
456                .iter()
457                .find(|rule| rule.id == id)
458                .expect("rule")
459                .actions
460                .clone()
461        };
462        assert!(actions_of("view-all").contains(&"agent.memory.read".to_string()));
463        assert!(
464            actions_of("deny-secret").contains(&"agent.memory.read".to_string()),
465            "denies mirror too, so read cannot outlive a view deny"
466        );
467        assert!(!actions_of("send-one").contains(&"agent.memory.read".to_string()));
468        // Idempotent: the normalized config now mentions memory.
469        assert!(!normalize_access_config_for_memory_actions(&mut config));
470    }
471
472    #[test]
473    fn config_mentioning_any_memory_action_is_taken_literally() {
474        // The pre-existing write action counts as "mentions memory": the
475        // author knew about memory actions, so the absence of read rules is
476        // an explicit choice.
477        let mut config = AccessControlConfig {
478            enabled: true,
479            admins: vec!["root@example.test".to_string()],
480            rules: vec![
481                rule("view-all", &["agent.view"]),
482                rule("writer", &["agent.memory.write"]),
483            ],
484            ..AccessControlConfig::default()
485        };
486        assert!(!normalize_access_config_for_memory_actions(&mut config));
487        assert!(
488            !config.rules[0]
489                .actions
490                .contains(&"agent.memory.read".to_string())
491        );
492
493        // A prefix wildcard naming the family counts as a mention as well.
494        let mut config = AccessControlConfig {
495            rules: vec![
496                rule("view-all", &["agent.view"]),
497                rule("mem", &["agent.memory.*"]),
498            ],
499            ..AccessControlConfig::default()
500        };
501        assert!(!normalize_access_config_for_memory_actions(&mut config));
502    }
503
504    #[test]
505    fn broad_wildcards_do_not_trigger_or_need_compat() {
506        // `agent.*` already matches agent.memory.read through pattern
507        // semantics, so the rule needs no rewrite; `*` likewise.
508        let mut config = AccessControlConfig {
509            rules: vec![rule("all-agent-verbs", &["agent.*"])],
510            ..AccessControlConfig::default()
511        };
512        assert!(!normalize_access_config_for_memory_actions(&mut config));
513        assert!(action_pattern_matches("agent.*", "agent.memory.read"));
514        assert!(action_pattern_matches("*", "memory.quarantine.review"));
515        assert!(!action_pattern_matches("agent.*", "mob.memory.read"));
516    }
517
518    #[test]
519    fn config_round_trips_through_toml() {
520        let config = AccessControlConfig {
521            enabled: true,
522            admins: vec!["root@example.test".to_string()],
523            groups: BTreeMap::from([(
524                "ops".to_string(),
525                AccessGroup {
526                    description: Some("Operations".to_string()),
527                    members: vec!["alice@example.test".to_string()],
528                },
529            )]),
530            rules: vec![AccessRule {
531                id: "ops-see-all".to_string(),
532                description: Some("ops see everything".to_string()),
533                effect: AccessEffect::Allow,
534                groups: vec!["ops".to_string()],
535                actions: vec!["agent.view".to_string()],
536                ..AccessRule::default()
537            }],
538        };
539        let toml = toml::to_string_pretty(&config).expect("serialize");
540        let parsed: AccessControlConfig = toml::from_str(&toml).expect("parse");
541        assert_eq!(parsed, config);
542    }
543}