Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/ENTERPRISE.md
Original file line number Diff line number Diff line change
@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.ManagedClient.preferences</string>
<key>PayloadIdentifier</key>
<string>dev.omt.apw.managed</string>
<key>PayloadUUID</key>
<string>00000000-0000-4000-8000-000000000051</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadEnabled</key>
<true/>
<key>PayloadContent</key>
<dict>
<key>dev.omt.apw</key>
<dict>
<key>Forced</key>
<array>
<dict>
<key>mcx_preference_settings</key>
<dict>
<key>fallbackProvider</key>
<string>1password</string>
<key>fallbackProviderPath</key>
<string>/Applications/1Password.app/Contents/MacOS/op</string>
<key>fallbackProviderTimeoutMs</key>
<integer>2500</integer>
<key>fallbackProviderMaxInvocations</key>
<integer>2</integer>
<key>supportedDomains</key>
<array>
<string>example.com</string>
<string>login.example.com</string>
</array>
<key>disableDemo</key>
<true/>
</dict>
</dict>
</array>
</dict>
</dict>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>APW Managed Settings</string>
<key>PayloadIdentifier</key>
<string>dev.omt.apw.profile</string>
<key>PayloadOrganization</key>
<string>Example Org</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>00000000-0000-4000-8000-000000000052</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
```
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down Expand Up @@ -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(),
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 76 additions & 9 deletions rust/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ pub struct DoctorCheck {
pub remediation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detected_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}

impl DoctorCheck {
Expand All @@ -51,6 +53,7 @@ impl DoctorCheck {
message: message.into(),
remediation: None,
detected_version: None,
details: None,
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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<DoctorCheck> {
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::<Vec<_>>()
})
.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;
}
Expand Down Expand Up @@ -309,6 +344,7 @@ pub fn run_environment_checks() -> Vec<DoctorCheck> {
check_detect_secrets(),
check_signing_identity(),
check_native_app_bundle(),
check_managed_config(),
];
if let Some(runner) = check_runner_labels() {
checks.push(runner);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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#"<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>fallbackProvider</key>
<string>bitwarden</string>
<key>supportedDomains</key>
<array><string>example.com</string></array>
</dict>
</plist>"#,
);
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.
Expand Down
30 changes: 30 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,20 @@ pub struct APWConfigV1 {
skip_serializing_if = "Option::is_none"
)]
pub fallback_provider_max_invocations: Option<u32>,
#[serde(
rename = "supportedDomains",
alias = "supported_domains",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub supported_domains: Vec<String>,
#[serde(
rename = "disableDemo",
alias = "disable_demo",
default,
skip_serializing_if = "Option::is_none"
)]
pub disable_demo: Option<bool>,
#[serde(rename = "createdAt")]
pub created_at: String,
}
Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -295,6 +311,18 @@ pub struct APWRuntimeConfig {
skip_serializing_if = "Option::is_none"
)]
pub fallback_provider_max_invocations: Option<u32>,
#[serde(
rename = "supportedDomains",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub supported_domains: Vec<String>,
#[serde(
rename = "disableDemo",
default,
skip_serializing_if = "Option::is_none"
)]
pub disable_demo: Option<bool>,
#[serde(rename = "createdAt")]
pub created_at: String,
}
Expand All @@ -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(),
}
}
Expand Down
Loading
Loading