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 4d7ee8f..49d7c49 100644
--- a/rust/src/client.rs
+++ b/rust/src/client.rs
@@ -623,6 +623,8 @@ impl ApplePasswordManager {
fallback_provider_database: 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(),
});
@@ -1074,6 +1076,8 @@ impl ApplePasswordManager {
fallback_provider_database: 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(),
});
@@ -2685,6 +2689,8 @@ mod tests {
fallback_provider_database: 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,
@@ -2780,6 +2786,8 @@ mod tests {
fallback_provider_database: 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..dae250c 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,44 @@ 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::configured_supported_domains_non_destructive);
+ let domains: Vec<&str> = configured_domains.iter().map(String::as_str).collect();
if domains.is_empty() {
return None;
}
@@ -309,6 +344,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 +383,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 +444,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 fc1d797..329ae43 100644
--- a/rust/src/types.rs
+++ b/rust/src/types.rs
@@ -200,6 +200,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,
}
@@ -226,6 +240,8 @@ impl Default for APWConfigV1 {
fallback_provider_database: None,
fallback_provider_timeout_ms: None,
fallback_provider_max_invocations: None,
+ supported_domains: Vec::new(),
+ disable_demo: None,
created_at: Utc::now().to_rfc3339(),
}
}
@@ -309,6 +325,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,
}
@@ -334,6 +362,8 @@ impl Default for APWRuntimeConfig {
fallback_provider_database: 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 72142f6..205d346 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);
@@ -237,6 +435,8 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 {
fallback_provider_database: 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,
@@ -249,7 +449,81 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 {
}
pub fn read_config_file() -> Result {
- read_config_file_or_null()
+ 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();
+
+ 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)) 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),
+ }
+}
+
+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")
+ ]
+ })
+}
+
+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(
@@ -351,6 +625,8 @@ pub fn read_config_file_or_empty() -> APWConfigV1 {
fallback_provider_database: 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(),
})
}
@@ -382,6 +658,8 @@ pub fn read_config(opts: Option) -> Result
fallback_provider_database: 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(),
});
}
@@ -414,6 +692,8 @@ pub fn read_config(opts: Option) -> Result
fallback_provider_database: 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(),
});
}
@@ -445,6 +725,8 @@ pub fn read_config(opts: Option) -> Result
fallback_provider_database: raw.fallback_provider_database,
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,
});
}
@@ -514,6 +796,8 @@ pub fn read_config(opts: Option) -> Result
fallback_provider_database: raw.fallback_provider_database,
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,
})
}
@@ -538,7 +822,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
@@ -747,6 +1031,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| {
@@ -924,6 +1213,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() {
@@ -967,6 +1288,156 @@ 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 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 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() {
+ 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() {
@@ -1034,6 +1505,8 @@ mod tests {
fallback_provider_database: 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,
@@ -1094,6 +1567,8 @@ mod tests {
fallback_provider_database: 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,
@@ -1171,6 +1646,8 @@ mod tests {
fallback_provider_database: 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,