From 382a4e64b422152b0c8937d8f0e5890c48821a44 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 23 May 2026 23:19:14 +0100 Subject: [PATCH 1/3] Add managed enterprise config Close #51 by reading managed macOS preferences from the dev.omt.apw domain before user config, surfacing per-setting provenance in doctor JSON, and documenting the MDM mobileconfig payload. Tests cover managed-over-user precedence and doctor provenance. --- docs/ENTERPRISE.md | 83 ++++++++++ docs/README.md | 1 + rust/src/client.rs | 8 + rust/src/doctor.rs | 89 +++++++++- rust/src/types.rs | 30 ++++ rust/src/utils.rs | 402 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 600 insertions(+), 13 deletions(-) create mode 100644 docs/ENTERPRISE.md diff --git a/docs/ENTERPRISE.md b/docs/ENTERPRISE.md new file mode 100644 index 0000000..47a196d --- /dev/null +++ b/docs/ENTERPRISE.md @@ -0,0 +1,83 @@ +# Enterprise Deployment + +APW supports managed macOS preferences for MDM deployments. Managed values are read from the `dev.omt.apw` preferences domain before `~/.apw/config.json`, so an organization can pin enterprise settings while still allowing per-user auth material to remain local. + +Managed keys: + +- `fallbackProvider`: external provider id, currently `1password` or `bitwarden`. +- `fallbackProviderPath`: absolute path to the provider executable. APW still validates ownership and executable permissions before use. +- `fallbackProviderTimeoutMs`: per-call provider timeout in milliseconds. +- `fallbackProviderMaxInvocations`: maximum provider invocations per broker request. +- `supportedDomains`: associated domains that the native app should treat as managed. +- `disableDemo`: disables demo affordances for managed deployments when `true`. + +`apw doctor --json` includes a `managed-config` check with per-setting provenance. Each managed key reports `"source": "managed"`; otherwise settings report `"user"` when that specific setting is present in `~/.apw/config.json` or `"default"` when APW is using the built-in default. + +Sample `.mobileconfig` payload: + +```xml + + + + + PayloadContent + + + PayloadType + com.apple.ManagedClient.preferences + PayloadIdentifier + dev.omt.apw.managed + PayloadUUID + 00000000-0000-4000-8000-000000000051 + PayloadVersion + 1 + PayloadEnabled + + PayloadContent + + dev.omt.apw + + Forced + + + mcx_preference_settings + + fallbackProvider + 1password + fallbackProviderPath + /Applications/1Password.app/Contents/MacOS/op + fallbackProviderTimeoutMs + 2500 + fallbackProviderMaxInvocations + 2 + supportedDomains + + example.com + login.example.com + + disableDemo + + + + + + + + + PayloadDisplayName + APW Managed Settings + PayloadIdentifier + dev.omt.apw.profile + PayloadOrganization + Example Org + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + 00000000-0000-4000-8000-000000000052 + PayloadVersion + 1 + + +``` diff --git a/docs/README.md b/docs/README.md index 492cd07..7cc7078 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ Start here for the maintained APW v2 documentation set. - [Installation and operation](INSTALLATION.md) - [Security posture and testing](SECURITY_POSTURE_AND_TESTING.md) +- [Enterprise deployment](ENTERPRISE.md) - [Threat model](THREAT_MODEL.md) - [Native migration matrix](NATIVE_MIGRATION.md) - [Native-only redesign notes](NATIVE_ONLY_REDESIGN.md) diff --git a/rust/src/client.rs b/rust/src/client.rs index ac5a847..b835eba 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -622,6 +622,8 @@ impl ApplePasswordManager { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc::now().timestamp().to_string(), }); @@ -1072,6 +1074,8 @@ impl ApplePasswordManager { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc::now().timestamp().to_string(), }); @@ -2682,6 +2686,8 @@ mod tests { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -2776,6 +2782,8 @@ mod tests { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: (chrono::Utc::now() - chrono::Duration::days(45)).to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, diff --git a/rust/src/doctor.rs b/rust/src/doctor.rs index a5b17a1..3761813 100644 --- a/rust/src/doctor.rs +++ b/rust/src/doctor.rs @@ -41,6 +41,8 @@ pub struct DoctorCheck { pub remediation: Option, #[serde(skip_serializing_if = "Option::is_none")] pub detected_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, } impl DoctorCheck { @@ -51,6 +53,7 @@ impl DoctorCheck { message: message.into(), remediation: None, detected_version: None, + details: None, } } @@ -63,6 +66,11 @@ impl DoctorCheck { self.detected_version = Some(version.into()); self } + + fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } } fn is_macos() -> bool { @@ -247,17 +255,48 @@ fn check_native_app_bundle() -> DoctorCheck { .with_remediation("Run `./scripts/build-native-app.sh`, then `apw app install`.") } +fn check_managed_config() -> DoctorCheck { + let details = crate::utils::config_provenance_details(); + let managed = details + .get("managed") + .and_then(Value::as_bool) + .unwrap_or(false); + if managed { + DoctorCheck::new( + "managed-config", + CheckStatus::Ok, + "Managed preferences are applied before user config.", + ) + .with_details(details) + } else { + DoctorCheck::new( + "managed-config", + CheckStatus::Skip, + "No managed preferences were found.", + ) + .with_details(details) + } +} + /// Probe each configured associated domain for a reachable AASA file. -/// Domains are read from `APW_AASA_DOMAINS` (comma-separated) so this can -/// be wired ahead of the `supportedDomains` config field landing. See -/// issue #8. +/// `APW_AASA_DOMAINS` remains a comma-separated override for CI probes; +/// otherwise the check uses user or managed `supportedDomains` config. fn check_associated_domains() -> Option { - let raw = std::env::var("APW_AASA_DOMAINS").ok()?; - let domains: Vec<&str> = raw - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .collect(); + let configured_domains = std::env::var("APW_AASA_DOMAINS") + .ok() + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_else(|| { + crate::utils::read_config_file() + .map(|config| config.supported_domains) + .unwrap_or_default() + }); + let domains: Vec<&str> = configured_domains.iter().map(String::as_str).collect(); if domains.is_empty() { return None; } @@ -309,6 +348,7 @@ pub fn run_environment_checks() -> Vec { check_detect_secrets(), check_signing_identity(), check_native_app_bundle(), + check_managed_config(), ]; if let Some(runner) = check_runner_labels() { checks.push(runner); @@ -347,6 +387,7 @@ pub fn checks_to_json(checks: &[DoctorCheck]) -> Value { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] fn check_status_label_is_uppercase() { @@ -407,12 +448,42 @@ mod tests { } #[test] + #[serial] + fn managed_config_check_reports_setting_provenance_in_json() { + std::env::set_var( + "APW_MANAGED_PREFS_PLIST", + r#" + + + fallbackProvider + bitwarden + supportedDomains + example.com + +"#, + ); + let check = check_managed_config(); + std::env::remove_var("APW_MANAGED_PREFS_PLIST"); + + assert_eq!(check.status, CheckStatus::Ok); + let details = check.details.expect("managed config details"); + assert_eq!(details["managed"], true); + assert!(details["settings"] + .as_array() + .unwrap() + .iter() + .any(|setting| setting["key"] == "supportedDomains" && setting["source"] == "managed")); + } + + #[test] + #[serial] fn associated_domains_check_skipped_when_env_unset() { std::env::remove_var("APW_AASA_DOMAINS"); assert!(check_associated_domains().is_none()); } #[test] + #[serial] fn associated_domains_check_reports_failure_for_unreachable_host() { // Use a guaranteed-unreachable .invalid TLD (RFC 2606). curl will // exit non-zero so the probe returns None and the check fails. diff --git a/rust/src/types.rs b/rust/src/types.rs index d5f73b9..98604a9 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -193,6 +193,20 @@ pub struct APWConfigV1 { skip_serializing_if = "Option::is_none" )] pub fallback_provider_max_invocations: Option, + #[serde( + rename = "supportedDomains", + alias = "supported_domains", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub supported_domains: Vec, + #[serde( + rename = "disableDemo", + alias = "disable_demo", + default, + skip_serializing_if = "Option::is_none" + )] + pub disable_demo: Option, #[serde(rename = "createdAt")] pub created_at: String, } @@ -218,6 +232,8 @@ impl Default for APWConfigV1 { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc::now().to_rfc3339(), } } @@ -295,6 +311,18 @@ pub struct APWRuntimeConfig { skip_serializing_if = "Option::is_none" )] pub fallback_provider_max_invocations: Option, + #[serde( + rename = "supportedDomains", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub supported_domains: Vec, + #[serde( + rename = "disableDemo", + default, + skip_serializing_if = "Option::is_none" + )] + pub disable_demo: Option, #[serde(rename = "createdAt")] pub created_at: String, } @@ -319,6 +347,8 @@ impl Default for APWRuntimeConfig { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc::now().to_rfc3339(), } } diff --git a/rust/src/utils.rs b/rust/src/utils.rs index 19ede6f..103206a 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -9,7 +9,7 @@ use chrono::{TimeZone, Utc}; use num_bigint::BigUint; use num_traits::{One, Zero}; use rand::RngCore; -use serde_json::Value; +use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::env; use std::fs::{self, OpenOptions}; @@ -17,6 +17,7 @@ use std::io::Write; use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use std::process::Command; pub const SESSION_MAX_AGE_MS: u64 = 30 * 24 * 60 * 60 * 1000; @@ -25,6 +26,8 @@ const CONFIG_FILE_MODE: u32 = 0o600; const CONFIG_SCHEMA: i32 = 1; const MAX_CONFIG_SIZE_BYTES: usize = 10 * 1024; const EXTERNAL_PROVIDER_MAX_MODE: u32 = 0o755; +const MANAGED_PREFS_DOMAIN: &str = "dev.omt.apw"; +const MANAGED_PREFS_TEST_PLIST_ENV: &str = "APW_MANAGED_PREFS_PLIST"; #[derive(Debug, Clone)] pub struct ConfigReadOptions { @@ -64,6 +67,17 @@ pub struct WriteConfigInput { pub refresh_created_at: bool, } +#[derive(Debug, Clone, Default)] +struct ManagedConfig { + fallback_provider: Option, + fallback_provider_path: Option, + fallback_provider_timeout_ms: Option, + fallback_provider_max_invocations: Option, + supported_domains: Option>, + disable_demo: Option, + managed_keys: Vec<&'static str>, +} + fn config_root() -> PathBuf { let home = env::var("HOME") .unwrap_or_else(|_| env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())); @@ -116,7 +130,187 @@ fn stale_config(created_at: &str, max_age_ms: u64) -> bool { .unwrap_or(true) } -fn read_config_file_or_null() -> Result { +fn decode_plist_text(value: &str) -> String { + value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") +} + +fn plist_value_region<'a>(plist: &'a str, key: &str) -> Option<&'a str> { + let marker = format!("{key}"); + let start = plist.find(&marker)? + marker.len(); + let remaining = &plist[start..]; + let end = remaining.find("").unwrap_or(remaining.len()); + Some(&remaining[..end]) +} + +fn plist_string_value(plist: &str, key: &str) -> Option { + let region = plist_value_region(plist, key)?; + let start = region.find("")? + "".len(); + let end = region[start..].find("")? + start; + Some(decode_plist_text(region[start..end].trim())) +} + +fn plist_u64_value(plist: &str, key: &str) -> Option { + let region = plist_value_region(plist, key)?; + let start = region.find("")? + "".len(); + let end = region[start..].find("")? + start; + region[start..end].trim().parse().ok() +} + +fn plist_bool_value(plist: &str, key: &str) -> Option { + let region = plist_value_region(plist, key)?; + if region.contains("") || region.contains("") { + Some(true) + } else if region.contains("") || region.contains("") { + Some(false) + } else { + None + } +} + +fn plist_string_array_value(plist: &str, key: &str) -> Option> { + let region = plist_value_region(plist, key)?; + let start = region.find("")? + "".len(); + let end = region[start..].find("")? + start; + let mut rest = ®ion[start..end]; + let mut values = Vec::new(); + while let Some(string_start) = rest.find("") { + let value_start = string_start + "".len(); + let Some(value_end) = rest[value_start..].find("") else { + break; + }; + let value_end = value_start + value_end; + let value = decode_plist_text(rest[value_start..value_end].trim()); + if !value.is_empty() { + values.push(value); + } + rest = &rest[value_end + "".len()..]; + } + Some(values) +} + +fn managed_prefs_plist() -> Option { + if let Ok(value) = env::var(MANAGED_PREFS_TEST_PLIST_ENV) { + return Some(value); + } + if cfg!(test) { + return None; + } + if !cfg!(target_os = "macos") { + return None; + } + let output = Command::new("defaults") + .args(["export", MANAGED_PREFS_DOMAIN, "-"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let plist = String::from_utf8(output.stdout).ok()?; + if plist.trim().is_empty() { + None + } else { + Some(plist) + } +} + +fn read_managed_config() -> Option { + let plist = managed_prefs_plist()?; + let mut managed = ManagedConfig::default(); + + if let Some(provider) = plist_string_value(&plist, "fallbackProvider") { + managed.fallback_provider = match provider.as_str() { + "1password" => Some(ExternalFallbackProvider::OnePassword), + "bitwarden" => Some(ExternalFallbackProvider::Bitwarden), + _ => None, + }; + if managed.fallback_provider.is_some() { + managed.managed_keys.push("fallbackProvider"); + } + } + if let Some(path) = plist_string_value(&plist, "fallbackProviderPath") { + managed.fallback_provider_path = Some(path); + managed.managed_keys.push("fallbackProviderPath"); + } + if let Some(timeout) = plist_u64_value(&plist, "fallbackProviderTimeoutMs") { + managed.fallback_provider_timeout_ms = Some(timeout); + managed.managed_keys.push("fallbackProviderTimeoutMs"); + } + if let Some(max_invocations) = plist_u64_value(&plist, "fallbackProviderMaxInvocations") { + if let Ok(value) = u32::try_from(max_invocations) { + managed.fallback_provider_max_invocations = Some(value); + managed.managed_keys.push("fallbackProviderMaxInvocations"); + } + } + if let Some(domains) = plist_string_array_value(&plist, "supportedDomains") { + managed.supported_domains = Some(domains); + managed.managed_keys.push("supportedDomains"); + } + if let Some(disabled) = plist_bool_value(&plist, "disableDemo") { + managed.disable_demo = Some(disabled); + managed.managed_keys.push("disableDemo"); + } + + if managed.managed_keys.is_empty() { + None + } else { + Some(managed) + } +} + +fn apply_managed_config(mut config: APWConfigV1, managed: &ManagedConfig) -> APWConfigV1 { + if let Some(provider) = managed.fallback_provider { + config.fallback_provider = Some(provider); + } + if let Some(path) = managed.fallback_provider_path.clone() { + config.fallback_provider_path = Some(path); + } + if let Some(timeout) = managed.fallback_provider_timeout_ms { + config.fallback_provider_timeout_ms = Some(timeout); + } + if let Some(max_invocations) = managed.fallback_provider_max_invocations { + config.fallback_provider_max_invocations = Some(max_invocations); + } + if let Some(domains) = managed.supported_domains.clone() { + config.supported_domains = domains; + } + if let Some(disabled) = managed.disable_demo { + config.disable_demo = Some(disabled); + } + config +} + +fn user_config_has_setting(key: &str) -> bool { + let Ok(raw) = fs::read_to_string(config_path()) else { + return false; + }; + let Ok(Value::Object(config)) = serde_json::from_str::(&raw) else { + return false; + }; + let aliases: &[&str] = match key { + "fallbackProvider" => &["fallbackProvider", "fallback_provider"], + "fallbackProviderPath" => &["fallbackProviderPath", "fallback_provider_path"], + "fallbackProviderTimeoutMs" => { + &["fallbackProviderTimeoutMs", "fallback_provider_timeout_ms"] + } + "fallbackProviderMaxInvocations" => &[ + "fallbackProviderMaxInvocations", + "fallback_provider_max_invocations", + ], + "supportedDomains" => &["supportedDomains", "supported_domains"], + "disableDemo" => &["disableDemo", "disable_demo"], + _ => &[key], + }; + aliases + .iter() + .any(|alias| config.get(*alias).is_some_and(|value| !value.is_null())) +} + +fn read_user_config_file_or_null() -> Result { let path = config_path(); let metadata = fs::symlink_metadata(&path).map_err(|_| { APWError::new( @@ -183,6 +377,10 @@ fn read_config_file_or_null() -> Result { Ok(normalize_legacy_config(legacy)) } +fn read_config_file_or_null() -> Result { + validate_external_provider_config(read_user_config_file_or_null()?) +} + fn validate_external_provider_config(mut config: APWConfigV1) -> Result { let Some(provider) = config.fallback_provider else { return Ok(config); @@ -236,6 +434,8 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, last_launch_status: None, last_launch_error: None, last_launch_strategy: None, @@ -248,7 +448,58 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 { } pub fn read_config_file() -> Result { - read_config_file_or_null() + let user = read_user_config_file_or_null(); + let managed = read_managed_config(); + + match (user, managed) { + (Ok(config), Some(managed_config)) => { + validate_external_provider_config(apply_managed_config(config, &managed_config)) + } + (Ok(config), None) => validate_external_provider_config(config), + (Err(_error), Some(managed_config)) => { + let base = APWConfigV1 { + created_at: Utc::now().to_rfc3339(), + ..APWConfigV1::default() + }; + validate_external_provider_config(apply_managed_config(base, &managed_config)) + } + (Err(error), None) => Err(error), + } +} + +pub fn config_provenance_details() -> Value { + let user_config_present = config_path().is_file(); + let managed = read_managed_config(); + let managed_keys = managed + .as_ref() + .map(|value| value.managed_keys.clone()) + .unwrap_or_default(); + let setting = |key: &'static str| { + json!({ + "key": key, + "source": if managed_keys.contains(&key) { + "managed" + } else if user_config_has_setting(key) { + "user" + } else { + "default" + } + }) + }; + + json!({ + "domain": MANAGED_PREFS_DOMAIN, + "managed": managed.is_some(), + "userConfigPresent": user_config_present, + "settings": [ + setting("fallbackProvider"), + setting("fallbackProviderPath"), + setting("fallbackProviderTimeoutMs"), + setting("fallbackProviderMaxInvocations"), + setting("supportedDomains"), + setting("disableDemo") + ] + }) } pub fn validate_external_provider_path( @@ -349,6 +600,8 @@ pub fn read_config_file_or_empty() -> APWConfigV1 { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }) } @@ -379,6 +632,8 @@ pub fn read_config(opts: Option) -> Result fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }); } @@ -410,6 +665,8 @@ pub fn read_config(opts: Option) -> Result fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }); } @@ -440,6 +697,8 @@ pub fn read_config(opts: Option) -> Result fallback_provider_path: raw.fallback_provider_path, fallback_provider_timeout_ms: raw.fallback_provider_timeout_ms, fallback_provider_max_invocations: raw.fallback_provider_max_invocations, + supported_domains: raw.supported_domains, + disable_demo: raw.disable_demo, created_at: raw.created_at, }); } @@ -508,6 +767,8 @@ pub fn read_config(opts: Option) -> Result fallback_provider_path: raw.fallback_provider_path, fallback_provider_timeout_ms: raw.fallback_provider_timeout_ms, fallback_provider_max_invocations: raw.fallback_provider_max_invocations, + supported_domains: raw.supported_domains, + disable_demo: raw.disable_demo, created_at: raw.created_at, }) } @@ -532,7 +793,7 @@ pub fn clear_config() { pub fn write_config(input: WriteConfigInput) -> Result { ensure_config_directory()?; - let existing = read_config_file().ok(); + let existing = read_config_file_or_null().ok(); let clear_auth = input.clear_auth; let port = input .port @@ -738,6 +999,11 @@ pub fn write_config(input: WriteConfigInput) -> Result { fallback_provider_max_invocations: existing .as_ref() .and_then(|value| value.fallback_provider_max_invocations), + supported_domains: existing + .as_ref() + .map(|value| value.supported_domains.clone()) + .unwrap_or_default(), + disable_demo: existing.as_ref().and_then(|value| value.disable_demo), }; let mut serialized = serde_json::to_string_pretty(&updated).map_err(|error| { @@ -915,6 +1181,38 @@ mod tests { config_root().join("config.json") } + fn managed_prefs_plist_for_test(provider_path: &Path) -> String { + format!( + r#" + + + + fallbackProvider + 1password + fallbackProviderPath + {} + fallbackProviderTimeoutMs + 2500 + fallbackProviderMaxInvocations + 2 + supportedDomains + + example.com + login.example.com + + disableDemo + + +"#, + provider_path.display() + ) + } + + fn write_test_provider(path: &Path) { + fs::write(path, "#!/bin/sh\nexit 0\n").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + #[test] #[serial] fn read_config_migrates_legacy_shape() { @@ -958,6 +1256,96 @@ mod tests { }); } + #[test] + #[serial] + fn read_config_applies_managed_preferences_before_user_config() { + with_temp_home(|| { + let managed_provider = config_root().join("managed-provider"); + let user_provider = config_root().join("user-provider"); + fs::create_dir_all(config_root()).unwrap(); + write_test_provider(&managed_provider); + write_test_provider(&user_provider); + + let user = APWConfigV1 { + username: "alice".to_string(), + shared_key: bigint_to_base64(&1u32.into()), + fallback_provider: Some(ExternalFallbackProvider::Bitwarden), + fallback_provider_path: Some(user_provider.display().to_string()), + fallback_provider_timeout_ms: Some(9000), + fallback_provider_max_invocations: Some(9), + supported_domains: vec!["user.example.com".to_string()], + disable_demo: Some(false), + ..APWConfigV1::default() + }; + fs::write( + config_path_for_test(), + serde_json::to_string(&user).unwrap(), + ) + .unwrap(); + + env::set_var( + MANAGED_PREFS_TEST_PLIST_ENV, + managed_prefs_plist_for_test(&managed_provider), + ); + let config = read_config_file().unwrap(); + let runtime = read_config(Some(ConfigReadOptions { + require_auth: false, + max_age_ms: SESSION_MAX_AGE_MS, + ignore_expiry: true, + })) + .unwrap(); + env::remove_var(MANAGED_PREFS_TEST_PLIST_ENV); + + assert_eq!( + config.fallback_provider, + Some(ExternalFallbackProvider::OnePassword) + ); + assert_eq!( + config.fallback_provider_path.as_deref(), + Some(managed_provider.canonicalize().unwrap().to_str().unwrap()) + ); + assert_eq!(config.fallback_provider_timeout_ms, Some(2500)); + assert_eq!(config.fallback_provider_max_invocations, Some(2)); + assert_eq!( + config.supported_domains, + vec!["example.com", "login.example.com"] + ); + assert_eq!(config.disable_demo, Some(true)); + assert_eq!( + runtime.supported_domains, + vec!["example.com", "login.example.com"] + ); + assert_eq!(runtime.disable_demo, Some(true)); + }); + } + + #[test] + #[serial] + fn config_provenance_reports_specific_setting_sources() { + with_temp_home(|| { + fs::create_dir_all(config_root()).unwrap(); + fs::write( + config_path_for_test(), + r#"{"schema":1,"port":10000,"host":"127.0.0.1","username":"alice","sharedKey":"","createdAt":"1970-01-01T00:00:00+00:00","fallbackProvider":null,"disableDemo":false}"#, + ) + .unwrap(); + + let details = config_provenance_details(); + let settings = details["settings"].as_array().unwrap(); + let source_for = |key: &str| { + settings + .iter() + .find(|setting| setting["key"] == key) + .and_then(|setting| setting["source"].as_str()) + .unwrap() + }; + + assert_eq!(source_for("disableDemo"), "user"); + assert_eq!(source_for("fallbackProvider"), "default"); + assert_eq!(source_for("supportedDomains"), "default"); + }); + } + #[test] #[serial] fn read_config_clears_invalid_json() { @@ -1024,6 +1412,8 @@ mod tests { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: (chrono::Utc::now() - chrono::Duration::days(40)).to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -1083,6 +1473,8 @@ mod tests { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -1159,6 +1551,8 @@ mod tests { fallback_provider_path: None, fallback_provider_timeout_ms: None, fallback_provider_max_invocations: None, + supported_domains: Vec::new(), + disable_demo: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, From f902e66f1c6f7438d9698fab8beafc1c6c42a334 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 05:46:03 +0100 Subject: [PATCH 2/3] Preserve invalid config errors under MDM --- rust/src/utils.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/rust/src/utils.rs b/rust/src/utils.rs index 103206a..f1cad30 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -448,6 +448,7 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 { } pub fn read_config_file() -> Result { + let user_config_was_present = fs::symlink_metadata(config_path()).is_ok(); let user = read_user_config_file_or_null(); let managed = read_managed_config(); @@ -456,13 +457,14 @@ pub fn read_config_file() -> Result { validate_external_provider_config(apply_managed_config(config, &managed_config)) } (Ok(config), None) => validate_external_provider_config(config), - (Err(_error), Some(managed_config)) => { + (Err(_error), Some(managed_config)) if !user_config_was_present => { let base = APWConfigV1 { created_at: Utc::now().to_rfc3339(), ..APWConfigV1::default() }; validate_external_provider_config(apply_managed_config(base, &managed_config)) } + (Err(error), Some(_)) => Err(error), (Err(error), None) => Err(error), } } @@ -1319,6 +1321,31 @@ mod tests { }); } + #[test] + #[serial] + fn managed_preferences_do_not_mask_invalid_user_config() { + with_temp_home(|| { + let managed_provider = config_root().join("managed-provider"); + fs::create_dir_all(config_root()).unwrap(); + write_test_provider(&managed_provider); + fs::write(config_path_for_test(), "{invalid").unwrap(); + + env::set_var( + MANAGED_PREFS_TEST_PLIST_ENV, + managed_prefs_plist_for_test(&managed_provider), + ); + let result = read_config_file(); + env::remove_var(MANAGED_PREFS_TEST_PLIST_ENV); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().code, + crate::types::Status::InvalidConfig + ); + assert!(!config_path_for_test().exists()); + }); + } + #[test] #[serial] fn config_provenance_reports_specific_setting_sources() { From be0031525163673b54e5505d8b025a03083ecd94 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:32:59 +0100 Subject: [PATCH 3/3] Keep doctor config reads non-destructive --- rust/src/doctor.rs | 6 +---- rust/src/utils.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/rust/src/doctor.rs b/rust/src/doctor.rs index 3761813..dae250c 100644 --- a/rust/src/doctor.rs +++ b/rust/src/doctor.rs @@ -291,11 +291,7 @@ fn check_associated_domains() -> Option { .map(str::to_string) .collect::>() }) - .unwrap_or_else(|| { - crate::utils::read_config_file() - .map(|config| config.supported_domains) - .unwrap_or_default() - }); + .unwrap_or_else(crate::utils::configured_supported_domains_non_destructive); let domains: Vec<&str> = configured_domains.iter().map(String::as_str).collect(); if domains.is_empty() { return None; diff --git a/rust/src/utils.rs b/rust/src/utils.rs index f1cad30..fe79ebe 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -504,6 +504,27 @@ pub fn config_provenance_details() -> Value { }) } +fn read_user_supported_domains_non_destructive() -> Vec { + let Ok(content) = fs::read_to_string(config_path()) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + serde_json::from_value::(parsed) + .ok() + .filter(|config| config.schema == CONFIG_SCHEMA) + .map(|config| config.supported_domains) + .unwrap_or_default() +} + +pub fn configured_supported_domains_non_destructive() -> Vec { + if let Some(domains) = read_managed_config().and_then(|managed| managed.supported_domains) { + return domains; + } + read_user_supported_domains_non_destructive() +} + pub fn validate_external_provider_path( provider: ExternalFallbackProvider, provider_path: &str, @@ -1346,6 +1367,41 @@ mod tests { }); } + #[test] + #[serial] + fn supported_domain_probe_read_preserves_invalid_user_config() { + with_temp_home(|| { + fs::create_dir_all(config_root()).unwrap(); + fs::write(config_path_for_test(), "{invalid").unwrap(); + + let domains = configured_supported_domains_non_destructive(); + + assert!(domains.is_empty()); + assert!(config_path_for_test().exists()); + }); + } + + #[test] + #[serial] + fn supported_domain_probe_read_uses_managed_domains_without_clearing_user_config() { + with_temp_home(|| { + let managed_provider = config_root().join("managed-provider"); + fs::create_dir_all(config_root()).unwrap(); + write_test_provider(&managed_provider); + fs::write(config_path_for_test(), "{invalid").unwrap(); + + env::set_var( + MANAGED_PREFS_TEST_PLIST_ENV, + managed_prefs_plist_for_test(&managed_provider), + ); + let domains = configured_supported_domains_non_destructive(); + env::remove_var(MANAGED_PREFS_TEST_PLIST_ENV); + + assert_eq!(domains, vec!["example.com", "login.example.com"]); + assert!(config_path_for_test().exists()); + }); + } + #[test] #[serial] fn config_provenance_reports_specific_setting_sources() {