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/// What a policy can require of `openmoat run` itself (ADR-077).
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum RunBoundary {
45 /// `openmoat run` starts only with its whole boundary: an OS sandbox and,
46 /// where the system has one, a network of the agent's own.
47 FullBoundary,
48}
49
50/// The `require:` section of a policy, a repository policy or a pack.
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Require {
54 /// The least level of every agent; absent, none.
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub protection: Option<RequiredLevel>,
57 /// What `openmoat run` must have before it starts anything; absent, what
58 /// it can get. Applies to every program it starts, agent or not.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub run: Option<RunBoundary>,
61 /// The least level of one agent, by its host id (as `openmoat status` names it), in
62 /// place of [`Self::protection`] for that agent in this file. Another
63 /// source's higher level still applies ([`Policy::required_for`]).
64 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
65 pub agents: BTreeMap<String, RequiredLevel>,
66}
67
68impl Require {
69 /// What this section asks of `host` (a host id), or of a program that is
70 /// no known agent when `host` is `None`: only [`Self::protection`].
71 #[must_use]
72 pub fn for_agent(&self, host: Option<&str>) -> Option<RequiredLevel> {
73 host.and_then(|h| self.agents.get(h).copied())
74 .or(self.protection)
75 }
76
77 /// Every host id [`Self::agents`] names, for the CLI to check against the
78 /// hosts it knows.
79 pub fn agent_ids(&self) -> impl Iterator<Item = &str> {
80 self.agents.keys().map(String::as_str)
81 }
82}
83
84/// Where a requirement in force comes from.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum RequiredBy {
87 /// The person's own `policy.yaml`.
88 Policy,
89 /// The project's repository policy (`.moat/policy.yaml`, ADR-022).
90 Repository,
91 /// The installed policy pack of this name (ADR-031).
92 Pack(String),
93}
94
95/// The level an agent must be at, and which policy asks for it.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Requirement {
98 /// The least level.
99 pub level: RequiredLevel,
100 /// The source that asks for it; of two that ask for the same level, the
101 /// person's own policy, then the first merged.
102 pub by: RequiredBy,
103}
104
105impl Policy {
106 /// The level `host` (a host id; `None` for a program that is no known
107 /// agent) must be at: the strictest that the policy and every repository
108 /// policy and pack merged into it ask for, or `None` when none does.
109 #[must_use]
110 pub fn required_for(&self, host: Option<&str>) -> Option<Requirement> {
111 let own = self.require.iter().map(|r| (RequiredBy::Policy, r));
112 let added = self.require_added.iter().map(|(by, r)| (by.clone(), r));
113 own.chain(added)
114 .filter_map(|(by, r)| r.for_agent(host).map(|level| Requirement { level, by }))
115 .fold(
116 None,
117 |strictest: Option<Requirement>, next| match strictest {
118 Some(kept) if kept.level >= next.level => Some(kept),
119 _ => Some(next),
120 },
121 )
122 }
123
124 /// Which policy requires `openmoat run` to start only with its full
125 /// boundary, or `None` when none does. Any one source is enough, so a
126 /// repository or a pack can turn it on and none can turn it off.
127 #[must_use]
128 pub fn run_boundary_required(&self) -> Option<RequiredBy> {
129 let own = self.require.iter().map(|r| (RequiredBy::Policy, r));
130 let added = self.require_added.iter().map(|(by, r)| (by.clone(), r));
131 own.chain(added)
132 .find_map(|(by, r)| r.run.map(|RunBoundary::FullBoundary| by))
133 }
134
135 /// Add the requirement `require` of `by` (a repository policy or a pack)
136 /// to the ones [`Self::required_for`] reads. It can only raise a level.
137 pub fn add_requirement(&mut self, by: RequiredBy, require: &Require) {
138 self.require_added.push((by, require.clone()));
139 }
140}
141
142/// An agent id must look like a host id; whether a host has it is the CLI's to
143/// check, since the core does not know the hosts.
144pub(super) fn check(require: Option<&Require>) -> Result<(), PolicyError> {
145 for id in require.iter().flat_map(|r| r.agent_ids()) {
146 let valid = !id.is_empty()
147 && id
148 .chars()
149 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
150 if !valid {
151 return Err(PolicyError::Rule {
152 rule: "require.agents".to_owned(),
153 problem: format!(
154 "`{id}` is not an agent id (lowercase letters, digits and `-`, as `openmoat status` names agents)"
155 ),
156 });
157 }
158 }
159 Ok(())
160}
161
162#[cfg(test)]
163mod tests;