Skip to main content

lean_ctx/core/config/
managed.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The machine's managed configuration (`leanctx.managed-config/v1`).
3//!
4//! An organisation preconfigures a machine through MDM. The carrier IO
5//! lives in `lean_ctx_fs::managed` and the schema in
6//! `lean_ctx_protocol::hive::managed`; this facade joins them, and holds the
7//! three decisions that need engine types:
8//!
9//! - [`Managed::apply_to_hive`]: the managed `hive.url`, `hive.root_key` and
10//!   `telemetry.*` overlay `[hive]` on every [`Config`](super::Config)
11//!   load. The overlay is never written back: `config set` and
12//!   `Config::update_global` read the user's file, not the loaded value.
13//! - [`refusal_for_key`]: `config set` of a managed key is refused.
14//! - [`require_gate`]: with `hive.require`, every tool refuses until the
15//!   machine is signed in (a live lease) and a Hive policy floor is installed.
16//!
17//! A carrier that exists but cannot be read or parsed fails closed: Hive
18//! calls refuse, `hive.*` settings refuse, and so does every tool (a broken
19//! carrier may have been the one that set `hive.require`).
20
21use std::sync::{Arc, Mutex, OnceLock};
22use std::time::{Duration, Instant};
23
24use lean_ctx_fs::managed::{Carrier, Owner};
25use lean_ctx_protocol::hive::managed::{ManagedCarrierKindV1, ManagedConfigV1};
26use lean_ctx_protocol::hive::status::{HiveStatusManagedMemberV1, HiveStatusManagedV1};
27
28use super::hive::HiveConfig;
29
30/// How long a read of the machine carrier is reused. Config loads happen
31/// per tool call; a profile change reaches a running process within this.
32const REREAD_AFTER: Duration = Duration::from_secs(5);
33
34/// A managed configuration found on this machine.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Managed {
37    pub carrier: ManagedCarrierKindV1,
38    /// Where the carrier lives (path or `HKLM\…`).
39    pub location: String,
40    /// The members, or why the carrier was refused.
41    pub config: Result<ManagedConfigV1, String>,
42}
43
44impl Managed {
45    /// Read `carrier`; `None` when it is absent or sets no member.
46    pub fn load(carrier: &Carrier, owner: Owner) -> Option<Self> {
47        let kind = match carrier {
48            Carrier::MacosProfile(_) => ManagedCarrierKindV1::MacosProfile,
49            Carrier::WindowsPolicy(..) => ManagedCarrierKindV1::WindowsPolicy,
50            Carrier::File(_) => ManagedCarrierKindV1::LinuxFile,
51        };
52        let config = match lean_ctx_fs::managed::read(carrier, owner) {
53            Ok(None) => return None,
54            Ok(Some(members)) => {
55                ManagedConfigV1::from_members(&members).map_err(|error| error.to_string())
56            }
57            Err(error) => Err(error),
58        };
59        if config.as_ref().is_ok_and(ManagedConfigV1::is_empty) {
60            return None;
61        }
62        Some(Self {
63            carrier: kind,
64            location: carrier.location(),
65            config,
66        })
67    }
68
69    /// Who manages this machine, for messages: the org when set.
70    pub fn manager(&self) -> String {
71        match self
72            .config
73            .as_ref()
74            .ok()
75            .and_then(|c| c.hive_org.as_deref())
76        {
77            Some(org) => format!("this machine is managed by {org}"),
78            None => "this machine is managed by your organisation".to_string(),
79        }
80    }
81
82    /// Overlay `[hive]`: the managed origin and root key win, a managed
83    /// `false` turns an upload off. A refused carrier changes nothing here;
84    /// the connector refuses on [`Managed::config`] instead.
85    pub fn apply_to_hive(&self, hive: &mut HiveConfig) {
86        let Ok(config) = &self.config else {
87            return;
88        };
89        if let Some(url) = &config.hive_url {
90            hive.url = Some(url.clone());
91        }
92        if let Some(key) = &config.hive_root_key {
93            hive.root_key = Some(key.clone());
94        }
95        if config.telemetry_metrics_upload == Some(false) {
96            hive.metrics_upload = false;
97        }
98        if config.telemetry_receipts_upload == Some(false) {
99            hive.receipts_upload = false;
100        }
101    }
102
103    /// Undo [`Managed::apply_to_hive`] for a save: every member the managed
104    /// configuration sets takes the value `user` (the file on disk) holds.
105    pub fn keep_user_values(&self, hive: &mut HiveConfig, user: &HiveConfig) {
106        let Ok(config) = &self.config else {
107            return;
108        };
109        if config.hive_url.is_some() {
110            hive.url.clone_from(&user.url);
111        }
112        if config.hive_root_key.is_some() {
113            hive.root_key.clone_from(&user.root_key);
114        }
115        if config.telemetry_metrics_upload == Some(false) {
116            hive.metrics_upload = user.metrics_upload;
117        }
118        if config.telemetry_receipts_upload == Some(false) {
119            hive.receipts_upload = user.receipts_upload;
120        }
121    }
122
123    /// Why `config set <key>` must not change `key`, if it must not.
124    pub fn refusal(&self, key: &str) -> Option<String> {
125        // Telemetry is tighten-only: only a managed `false` binds the user.
126        let binds = |config: &ManagedConfigV1| match key {
127            "hive.url" => config.sets("hive.url"),
128            "hive.root_key" => config.sets("hive.root_key"),
129            "hive.metrics_upload" => config.telemetry_metrics_upload == Some(false),
130            "hive.receipts_upload" => config.telemetry_receipts_upload == Some(false),
131            _ => false,
132        };
133        if !matches!(
134            key,
135            "hive.url" | "hive.root_key" | "hive.metrics_upload" | "hive.receipts_upload"
136        ) {
137            return None;
138        }
139        match &self.config {
140            Err(error) => Some(format!(
141                "the managed configuration ({}) is refused ({error}); `{key}` cannot be changed until your organisation fixes it",
142                self.location
143            )),
144            Ok(config) if binds(config) => Some(format!(
145                "{}: `{key}` is set by the managed configuration ({}); a user setting cannot override it",
146                self.manager(),
147                self.location
148            )),
149            Ok(_) => None,
150        }
151    }
152
153    /// Why `org_id` may not be connected to, if it may not.
154    pub fn org_refusal(&self, org_id: &str) -> Option<String> {
155        match &self.config {
156            Err(error) => Some(format!(
157                "the managed configuration ({}) is refused: {error}",
158                self.location
159            )),
160            Ok(config) => config
161                .hive_org
162                .as_deref()
163                .filter(|managed| *managed != org_id)
164                .map(|managed| {
165                    format!("this machine is managed by {managed}: it cannot connect to the org {org_id}")
166                }),
167        }
168    }
169
170    /// `managed` of `hive status --json`.
171    pub fn wire(&self) -> HiveStatusManagedV1 {
172        let (members, digest, error) = match &self.config {
173            Ok(config) => (
174                config
175                    .members()
176                    .into_iter()
177                    .map(|(member, value)| HiveStatusManagedMemberV1 {
178                        member: member.to_string(),
179                        value,
180                    })
181                    .collect(),
182                Some(config.digest()),
183                None,
184            ),
185            Err(error) => (Vec::new(), None, Some(error.clone())),
186        };
187        HiveStatusManagedV1 {
188            carrier: self.carrier,
189            location: self.location.clone(),
190            members,
191            digest,
192            error,
193        }
194    }
195}
196
197type Cached = Option<(Instant, Option<Arc<Managed>>)>;
198
199/// This machine's managed configuration, re-read every few seconds.
200pub fn current() -> Option<Arc<Managed>> {
201    static CACHE: Mutex<Cached> = Mutex::new(None);
202    #[cfg(test)]
203    if let Some(TestOverride(managed)) = TEST_MANAGED.with(|cell| cell.borrow().clone()) {
204        return managed;
205    }
206    if let Ok(guard) = CACHE.lock()
207        && let Some((at, ref managed)) = *guard
208        && at.elapsed() < REREAD_AFTER
209    {
210        return managed.clone();
211    }
212    let managed = Managed::load(&lean_ctx_fs::managed::system_carrier(), Owner::Root).map(Arc::new);
213    if let Ok(mut guard) = CACHE.lock() {
214        *guard = Some((Instant::now(), managed.clone()));
215    }
216    managed
217}
218
219/// What [`current`] answers on a test thread instead of the machine carrier.
220#[cfg(test)]
221#[derive(Clone)]
222struct TestOverride(Option<Arc<Managed>>);
223
224#[cfg(test)]
225thread_local! {
226    static TEST_MANAGED: std::cell::RefCell<Option<TestOverride>> =
227        const { std::cell::RefCell::new(None) };
228}
229
230/// Run `body` with [`current`] answering `managed` on this thread, so the
231/// callers' wiring is tested without the root-owned machine carrier.
232#[cfg(test)]
233pub(crate) fn with_managed_for_test<R>(managed: Option<Managed>, body: impl FnOnce() -> R) -> R {
234    struct Reset;
235    impl Drop for Reset {
236        fn drop(&mut self) {
237            TEST_MANAGED.with(|cell| cell.borrow_mut().take());
238        }
239    }
240    TEST_MANAGED.with(|cell| *cell.borrow_mut() = Some(TestOverride(managed.map(Arc::new))));
241    let _reset = Reset;
242    body()
243}
244
245/// A managed configuration read from `toml` (tests).
246#[cfg(test)]
247pub(crate) fn managed_from_toml_for_test(toml: &str) -> Managed {
248    let dir = tempfile::tempdir().expect("tempdir");
249    let path = dir.path().join("managed.toml");
250    std::fs::write(&path, toml).expect("write");
251    Managed::load(&Carrier::File(path), Owner::Any).expect("present")
252}
253
254/// What the config cache keys on: a changed managed configuration is a
255/// changed config.
256pub(crate) fn cache_key() -> Option<String> {
257    current().map(|managed| match &managed.config {
258        Ok(config) => config.digest(),
259        Err(error) => format!("refused:{error}"),
260    })
261}
262
263/// `config set` refusal for `key` under this machine's managed configuration.
264pub fn refusal_for_key(key: &str) -> Option<String> {
265    current()?.refusal(key)
266}
267
268/// Whether this machine is signed in to its Hive with a live lease.
269pub type SignedInProbe = fn() -> bool;
270
271static SIGNED_IN: OnceLock<SignedInProbe> = OnceLock::new();
272
273/// Register the connector's sign-in probe (the first registration wins).
274/// Without one, `hive.require` refuses every tool.
275pub fn register_signed_in_probe(probe: SignedInProbe) {
276    let _ = SIGNED_IN.set(probe);
277}
278
279/// The `hive.require` gate: `Err` (every tool refuses) while a managed
280/// configuration requires a sign-in and there is no live lease or no
281/// installed policy floor, or while the carrier is refused.
282pub fn require_gate(floor_installed: bool) -> Result<(), String> {
283    match current() {
284        Some(managed) => gate(&managed, floor_installed, SIGNED_IN.get().copied()),
285        None => Ok(()),
286    }
287}
288
289fn gate(
290    managed: &Managed,
291    floor_installed: bool,
292    probe: Option<SignedInProbe>,
293) -> Result<(), String> {
294    let config = match &managed.config {
295        Ok(config) => config,
296        Err(error) => {
297            return Err(format!(
298                "the managed configuration ({}) is refused: {error}. Tools stay off until your organisation fixes it",
299                managed.location
300            ));
301        }
302    };
303    if config.hive_require != Some(true) {
304        return Ok(());
305    }
306    let signed_in = probe.is_some_and(|probe| probe());
307    if !signed_in {
308        return Err(format!(
309            "{}: sign in first (`lean-ctx hive connect`). Tools stay off until this machine holds a valid Hive lease",
310            managed.manager()
311        ));
312    }
313    if !floor_installed {
314        return Err(format!(
315            "{}: the organisation's policy is not installed yet. Tools stay off until the next Hive pull installs it (`lean-ctx hive sync`)",
316            managed.manager()
317        ));
318    }
319    Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn managed(toml: &str) -> Managed {
327        managed_from_toml_for_test(toml)
328    }
329
330    const KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
331
332    #[test]
333    fn managed_hive_members_win_over_the_user_and_refuse_overrides() {
334        let managed = managed(&format!(
335            "[hive]\nurl = \"https://hive.acme.example\"\nroot_key = \"{KEY}\"\norg = \"org_acme\"\n\
336             [telemetry]\nmetrics_upload = false\nreceipts_upload = true\n"
337        ));
338        let user = HiveConfig {
339            url: Some("https://hive.user.example".into()),
340            root_key: None,
341            metrics_upload: true,
342            receipts_upload: true,
343            ..HiveConfig::default()
344        };
345        let mut hive = user.clone();
346        managed.apply_to_hive(&mut hive);
347        // A save writes the user's values back, never the overlay.
348        let mut saved = hive.clone();
349        managed.keep_user_values(&mut saved, &user);
350        assert_eq!(saved, user);
351        assert_eq!(hive.url.as_deref(), Some("https://hive.acme.example"));
352        assert_eq!(hive.root_key.as_deref(), Some(KEY));
353        // Tighten-only: false turns off, true leaves the user's choice.
354        assert!(!hive.metrics_upload && hive.receipts_upload);
355
356        let refusal = managed.refusal("hive.url").expect("refused");
357        assert!(refusal.contains("managed by org_acme"), "{refusal}");
358        assert!(managed.refusal("hive.metrics_upload").is_some());
359        assert!(managed.refusal("hive.receipts_upload").is_none());
360        assert!(managed.refusal("hive.auto_capture").is_none());
361
362        assert!(managed.org_refusal("org_acme").is_none());
363        let other = managed.org_refusal("org_other").expect("refused");
364        assert!(other.contains("managed by org_acme"), "{other}");
365
366        let wire = managed.wire();
367        assert_eq!(wire.carrier, ManagedCarrierKindV1::LinuxFile);
368        assert_eq!(wire.members[0].member, "hive.url");
369        assert!(wire.digest.is_some() && wire.error.is_none());
370    }
371
372    #[test]
373    fn require_refuses_tools_until_signed_in_with_a_floor() {
374        let managed = managed("[hive]\nrequire = true\norg = \"org_acme\"\n");
375        let no_probe = gate(&managed, true, None).expect_err("no probe");
376        assert!(no_probe.contains("sign in first"), "{no_probe}");
377        assert!(gate(&managed, true, Some(|| false)).is_err());
378        let no_floor = gate(&managed, false, Some(|| true)).expect_err("no floor");
379        assert!(no_floor.contains("policy is not installed"), "{no_floor}");
380        assert_eq!(gate(&managed, true, Some(|| true)), Ok(()));
381
382        let relaxed = self::managed("[hive]\norg = \"org_acme\"\n");
383        assert_eq!(gate(&relaxed, false, None), Ok(()));
384    }
385
386    #[test]
387    fn a_refused_carrier_fails_closed() {
388        let broken = managed("[hive]\nrequire = \"maybe\"\n");
389        assert!(broken.config.is_err());
390        assert!(gate(&broken, true, Some(|| true)).is_err());
391        assert!(broken.refusal("hive.url").is_some());
392        assert!(broken.org_refusal("org_any").is_some());
393        let mut hive = HiveConfig::default();
394        broken.apply_to_hive(&mut hive);
395        assert_eq!(hive, HiveConfig::default());
396        assert!(broken.wire().error.is_some());
397    }
398}