Skip to main content

lean_ctx_protocol/hive/
managed.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `leanctx.managed-config/v1`: what an organisation preconfigures on a
4//! machine through MDM (contract: `docs/contracts/managed-config-v1.md`).
5//!
6//! The carrier (a macOS profile, the Windows policy key, or
7//! `/etc/lean-ctx/managed.toml`) delivers members either nested
8//! (`{"hive": {"url": …}}`) or dotted (`{"hive.url": …}`, the natural form of
9//! a registry value name); [`ManagedConfigV1::from_members`] accepts both.
10//! The configuration names the Hive, the org, the IdP hint and the update
11//! mode. It carries no secret and no policy content. Every member is
12//! optional; an unknown member, a wrong type or a value out of shape refuses
13//! the whole configuration, and the engine then fails closed.
14//!
15//! Precedence: managed > Hive floor > the user's `config.toml` > defaults.
16//! `telemetry.*` members only tighten: `false` turns an upload off, `true`
17//! changes nothing.
18
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21
22use super::effective_policy::policy_digest;
23use crate::ValidationError;
24
25pub const MANAGED_CONFIG_SCHEMA: &str = "leanctx.managed-config/v1";
26
27/// The update channel an organisation chose (`updates.mode`).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ManagedUpdateModeV1 {
31    /// The MDM ships updates; the engine never updates itself.
32    Mdm,
33    /// The Hive names the version.
34    Hive,
35    /// The user decides (the unmanaged behaviour).
36    User,
37}
38
39/// A validated managed configuration. `None`/empty: not managed.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct ManagedConfigV1 {
43    /// Hive web origin; wins over `[hive] url`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub hive_url: Option<String>,
46    /// Base64 Ed25519 root key; wins over `[hive] root_key`.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub hive_root_key: Option<String>,
49    /// The only org this machine may connect to (org wire id).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub hive_org: Option<String>,
52    /// Identity-provider hint for sign-in.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub hive_idp_hint: Option<String>,
55    /// Tools refuse until this machine is signed in with a live lease and
56    /// a policy floor is installed.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub hive_require: Option<bool>,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub workspace_vaults: Vec<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub updates_mode: Option<ManagedUpdateModeV1>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub updates_feed_base: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub telemetry_metrics_upload: Option<bool>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub telemetry_receipts_upload: Option<bool>,
69}
70
71#[derive(Clone, Copy)]
72enum Kind {
73    Text,
74    Origin,
75    Https,
76    RootKey,
77    Id,
78    Bool,
79    List,
80    Mode,
81}
82
83/// Every member of v1, by its dotted name.
84const MEMBERS: &[(&str, Kind)] = &[
85    ("hive.url", Kind::Origin),
86    ("hive.root_key", Kind::RootKey),
87    ("hive.org", Kind::Id),
88    ("hive.idp_hint", Kind::Text),
89    ("hive.require", Kind::Bool),
90    ("workspace.vaults", Kind::List),
91    ("updates.mode", Kind::Mode),
92    ("updates.feed_base", Kind::Https),
93    ("telemetry.metrics_upload", Kind::Bool),
94    ("telemetry.receipts_upload", Kind::Bool),
95];
96
97impl ManagedConfigV1 {
98    /// Parse the members a carrier holds. `schema`, when present, must be
99    /// [`MANAGED_CONFIG_SCHEMA`].
100    pub fn from_members(members: &Map<String, Value>) -> Result<Self, ValidationError> {
101        let mut flat = Map::new();
102        for (key, value) in members {
103            if key == "schema" {
104                if value.as_str() != Some(MANAGED_CONFIG_SCHEMA) {
105                    return Err(ValidationError::new(format!(
106                        "schema must be \"{MANAGED_CONFIG_SCHEMA}\""
107                    )));
108                }
109                continue;
110            }
111            flatten(key, value, &mut flat)?;
112        }
113        let mut out = Map::new();
114        for (name, value) in flat {
115            let Some((_, kind)) = MEMBERS.iter().find(|(member, _)| *member == name) else {
116                return Err(ValidationError::new(format!("unknown member `{name}`")));
117            };
118            let value = coerce(&name, *kind, value)?;
119            out.insert(name.replace('.', "_"), value);
120        }
121        serde_json::from_value(Value::Object(out))
122            .map_err(|error| ValidationError::new(format!("managed configuration: {error}")))
123    }
124
125    /// No member is set.
126    pub fn is_empty(&self) -> bool {
127        self == &Self::default()
128    }
129
130    /// The members that are set, by dotted name, with their value as text
131    /// (lists joined by `, `), in schema order.
132    pub fn members(&self) -> Vec<(&'static str, String)> {
133        let Ok(Value::Object(map)) = serde_json::to_value(self) else {
134            return Vec::new();
135        };
136        MEMBERS
137            .iter()
138            .filter_map(|(name, _)| {
139                let value = map.get(&name.replace('.', "_"))?;
140                let text = match value {
141                    Value::String(text) => text.clone(),
142                    Value::Array(items) => items
143                        .iter()
144                        .filter_map(Value::as_str)
145                        .collect::<Vec<_>>()
146                        .join(", "),
147                    other => other.to_string(),
148                };
149                Some((*name, text))
150            })
151            .collect()
152    }
153
154    /// Whether `member` (dotted, e.g. `hive.url`) is set.
155    pub fn sets(&self, member: &str) -> bool {
156        self.members().iter().any(|(name, _)| *name == member)
157    }
158
159    /// `managed_digest`: `"sha256:" + hex(sha256(canonical_json))` of the
160    /// configuration with its schema, for the audit trail.
161    pub fn digest(&self) -> String {
162        let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
163        if let Value::Object(map) = &mut value {
164            map.insert("schema".into(), Value::from(MANAGED_CONFIG_SCHEMA));
165        }
166        policy_digest(&value).unwrap_or_default()
167    }
168}
169
170/// Nested objects become dotted names; a name may already be dotted.
171fn flatten(
172    prefix: &str,
173    value: &Value,
174    out: &mut Map<String, Value>,
175) -> Result<(), ValidationError> {
176    match value {
177        Value::Object(inner) => {
178            for (key, value) in inner {
179                flatten(&format!("{prefix}.{key}"), value, out)?;
180            }
181            Ok(())
182        }
183        other => {
184            if out.insert(prefix.to_string(), other.clone()).is_some() {
185                return Err(ValidationError::new(format!(
186                    "member `{prefix}` is set twice"
187                )));
188            }
189            Ok(())
190        }
191    }
192}
193
194fn coerce(name: &str, kind: Kind, value: Value) -> Result<Value, ValidationError> {
195    let refuse = |what: &str| ValidationError::new(format!("member `{name}` must be {what}"));
196    let text = |value: &Value| -> Result<String, ValidationError> {
197        match value.as_str().map(str::trim) {
198            Some(text) if !text.is_empty() && text.len() <= 512 => Ok(text.to_string()),
199            _ => Err(refuse("a non-empty string")),
200        }
201    };
202    Ok(match kind {
203        Kind::Text => Value::from(text(&value)?),
204        Kind::Origin => {
205            let url = text(&value)?;
206            if !is_origin(&url) {
207                return Err(refuse(
208                    "an https origin (http only for loopback), without path or query",
209                ));
210            }
211            Value::from(url)
212        }
213        Kind::Https => {
214            let url = text(&value)?;
215            if !url.starts_with("https://") || url.len() <= "https://".len() {
216                return Err(refuse("an https URL"));
217            }
218            Value::from(url)
219        }
220        Kind::RootKey => {
221            let key = text(&value)?;
222            let base64 = |c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/';
223            if key.len() != 44 || !key.ends_with('=') || !key[..43].chars().all(base64) {
224                return Err(refuse(
225                    "a standard base64 Ed25519 public key (44 characters)",
226                ));
227            }
228            Value::from(key)
229        }
230        Kind::Id => {
231            let id = text(&value)?;
232            if id.len() > 128 || id.chars().any(char::is_whitespace) {
233                return Err(refuse("an org id without whitespace"));
234            }
235            Value::from(id)
236        }
237        Kind::Bool => Value::from(match &value {
238            Value::Bool(flag) => *flag,
239            Value::Number(number) if number.as_u64() == Some(0) => false,
240            Value::Number(number) if number.as_u64() == Some(1) => true,
241            Value::String(text) if matches!(text.trim(), "true" | "1") => true,
242            Value::String(text) if matches!(text.trim(), "false" | "0") => false,
243            _ => return Err(refuse("true or false (or 1/0)")),
244        }),
245        Kind::List => {
246            let items: Vec<Value> = match &value {
247                Value::Array(items) => items.clone(),
248                Value::String(joined) => joined
249                    .split([',', '\n'])
250                    .filter(|part| !part.trim().is_empty())
251                    .map(|part| Value::from(part.trim()))
252                    .collect(),
253                _ => return Err(refuse("a list of strings")),
254            };
255            let items = items.iter().map(text).collect::<Result<Vec<_>, _>>()?;
256            Value::from(items)
257        }
258        Kind::Mode => {
259            let mode = text(&value)?;
260            if !matches!(mode.as_str(), "mdm" | "hive" | "user") {
261                return Err(refuse("one of mdm, hive, user"));
262            }
263            Value::from(mode)
264        }
265    })
266}
267
268/// `https://host[:port]` (or `http://` for loopback), an optional trailing
269/// `/`, nothing else.
270fn is_origin(url: &str) -> bool {
271    let (scheme, rest) = match url.split_once("://") {
272        Some(parts) => parts,
273        None => return false,
274    };
275    let authority = rest.strip_suffix('/').unwrap_or(rest);
276    if authority.is_empty() || authority.contains(['/', '?', '#', '@', ' ']) {
277        return false;
278    }
279    let host = authority
280        .rsplit_once(':')
281        .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()) && !authority.ends_with(']'))
282        .map_or(authority, |(host, _)| host);
283    match scheme {
284        "https" => true,
285        "http" => matches!(host, "localhost" | "127.0.0.1" | "[::1]"),
286        _ => false,
287    }
288}
289
290/// Where a managed configuration came from (`hive status`).
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum ManagedCarrierKindV1 {
294    MacosProfile,
295    WindowsPolicy,
296    LinuxFile,
297    #[serde(other)]
298    Unknown,
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use serde_json::json;
305
306    fn parse(value: Value) -> Result<ManagedConfigV1, ValidationError> {
307        match value {
308            Value::Object(map) => ManagedConfigV1::from_members(&map),
309            _ => unreachable!(),
310        }
311    }
312
313    const KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
314
315    #[test]
316    fn nested_and_dotted_carriers_mean_the_same_configuration() {
317        let nested = parse(json!({
318            "schema": MANAGED_CONFIG_SCHEMA,
319            "hive": {"url": "https://hive.acme.example", "root_key": KEY, "org": "org_1", "require": true},
320            "workspace": {"vaults": ["acme/main"]},
321            "updates": {"mode": "mdm"},
322            "telemetry": {"metrics_upload": false},
323        }))
324        .expect("valid");
325        // Registry shape: dotted names, DWORD booleans, a joined list.
326        let dotted = parse(json!({
327            "hive.url": "https://hive.acme.example",
328            "hive.root_key": KEY,
329            "hive.org": "org_1",
330            "hive.require": 1,
331            "workspace.vaults": "acme/main",
332            "updates.mode": "mdm",
333            "telemetry.metrics_upload": 0,
334        }))
335        .expect("valid");
336        assert_eq!(nested, dotted);
337        assert_eq!(nested.digest(), dotted.digest());
338        assert!(nested.digest().starts_with("sha256:"));
339        assert!(nested.sets("hive.require") && !nested.sets("hive.idp_hint"));
340        assert_eq!(
341            nested.members()[0],
342            ("hive.url", "https://hive.acme.example".into())
343        );
344    }
345
346    #[test]
347    fn a_configuration_out_of_shape_is_refused_whole() {
348        for bad in [
349            json!({"schema": "leanctx.managed-config/v2"}),
350            json!({"hive": {"rquire": true}}),
351            json!({"hive.url": "http://hive.acme.example"}),
352            json!({"hive.url": "https://hive.acme.example/v1"}),
353            json!({"hive.root_key": "not-a-key"}),
354            json!({"hive.require": "yes"}),
355            json!({"hive": {"org": "a"}, "hive.org": "b"}),
356            json!({"updates.mode": "auto"}),
357            json!({"updates.feed_base": "http://feed.example"}),
358            json!({"hive.org": "org 1"}),
359        ] {
360            assert!(parse(bad.clone()).is_err(), "accepted {bad}");
361        }
362        assert!(parse(json!({"hive.url": "http://127.0.0.1:8080"})).is_ok());
363        assert!(parse(json!({})).expect("empty").is_empty());
364    }
365}