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