openmoat_core/policy/require.rs
1//! The `require:` section (ADR-064): the least protection level an agent must
2//! be at for OpenMoat to let any of its calls through. The level itself is
3//! worked out by the CLI, which can see the hook and the sandboxes; this
4//! module only holds what the policies ask for and which of them asks most.
5//!
6//! A repository policy and a policy pack can add a requirement, never take one
7//! away: [`Policy::required_for`] is the strictest of every source, so a lower
8//! level from one of them leaves a higher one from another in force.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14use super::{Policy, PolicyError};
15
16/// A protection level a policy can require, weakest first, so the derived
17/// order is "at least as protected as".
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum RequiredLevel {
21 /// OpenMoat's hook decides the agent's calls (`openmoat status`'s
22 /// `hook-only` or better).
23 HookOnly,
24 /// The hook decides, and an OS sandbox from OpenMoat holds the commands
25 /// the agent runs (`hook-and-os-sandbox`): the host's own sandbox as
26 /// OpenMoat configured it, or `openmoat run`.
27 HookAndOsSandbox,
28}
29
30impl RequiredLevel {
31 /// The level as `require:` and `openmoat status --format json` spell it.
32 #[must_use]
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::HookOnly => "hook-only",
36 Self::HookAndOsSandbox => "hook-and-os-sandbox",
37 }
38 }
39}
40
41/// The `require:` section of a policy, a repository policy or a pack.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Require {
45 /// The least level of every agent; absent, none.
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub protection: Option<RequiredLevel>,
48 /// The least level of one agent, by its host id (as `openmoat status` names it), in
49 /// place of [`Self::protection`] for that agent in this file. Another
50 /// source's higher level still applies ([`Policy::required_for`]).
51 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
52 pub agents: BTreeMap<String, RequiredLevel>,
53}
54
55impl Require {
56 /// What this section asks of `host` (a host id), or of a program that is
57 /// no known agent when `host` is `None`: only [`Self::protection`].
58 #[must_use]
59 pub fn for_agent(&self, host: Option<&str>) -> Option<RequiredLevel> {
60 host.and_then(|h| self.agents.get(h).copied())
61 .or(self.protection)
62 }
63
64 /// Every host id [`Self::agents`] names, for the CLI to check against the
65 /// hosts it knows.
66 pub fn agent_ids(&self) -> impl Iterator<Item = &str> {
67 self.agents.keys().map(String::as_str)
68 }
69}
70
71/// Where a requirement in force comes from.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum RequiredBy {
74 /// The person's own `policy.yaml`.
75 Policy,
76 /// The project's repository policy (`.moat/policy.yaml`, ADR-022).
77 Repository,
78 /// The installed policy pack of this name (ADR-031).
79 Pack(String),
80}
81
82/// The level an agent must be at, and which policy asks for it.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Requirement {
85 /// The least level.
86 pub level: RequiredLevel,
87 /// The source that asks for it; of two that ask for the same level, the
88 /// person's own policy, then the first merged.
89 pub by: RequiredBy,
90}
91
92impl Policy {
93 /// The level `host` (a host id; `None` for a program that is no known
94 /// agent) must be at: the strictest that the policy and every repository
95 /// policy and pack merged into it ask for, or `None` when none does.
96 #[must_use]
97 pub fn required_for(&self, host: Option<&str>) -> Option<Requirement> {
98 let own = self.require.iter().map(|r| (RequiredBy::Policy, r));
99 let added = self.require_added.iter().map(|(by, r)| (by.clone(), r));
100 own.chain(added)
101 .filter_map(|(by, r)| r.for_agent(host).map(|level| Requirement { level, by }))
102 .fold(
103 None,
104 |strictest: Option<Requirement>, next| match strictest {
105 Some(kept) if kept.level >= next.level => Some(kept),
106 _ => Some(next),
107 },
108 )
109 }
110
111 /// Add the requirement `require` of `by` (a repository policy or a pack)
112 /// to the ones [`Self::required_for`] reads. It can only raise a level.
113 pub fn add_requirement(&mut self, by: RequiredBy, require: &Require) {
114 self.require_added.push((by, require.clone()));
115 }
116}
117
118/// An agent id must look like a host id; whether a host has it is the CLI's to
119/// check, since the core does not know the hosts.
120pub(super) fn check(require: Option<&Require>) -> Result<(), PolicyError> {
121 for id in require.iter().flat_map(|r| r.agent_ids()) {
122 let valid = !id.is_empty()
123 && id
124 .chars()
125 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
126 if !valid {
127 return Err(PolicyError::Rule {
128 rule: "require.agents".to_owned(),
129 problem: format!(
130 "`{id}` is not an agent id (lowercase letters, digits and `-`, as `openmoat status` names agents)"
131 ),
132 });
133 }
134 }
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests;