Managed Preferences 到底贏不贏得過本機設定?自己寫工具測給自己看
在管理一批用 MDM 集中控管的 Mac 時,常常會遇到一個問題:某個 preference key,使用者自己用 defaults write 改過一次,管理員可能又用 sudo defaults write 在系統層級動過一次,最後 MDM 又把同一個 key 標成 Managed(強制)。這時候系統實際生效的到底是哪一個值?
Apple 官方文件對 CFPreferencesAppValueIsForced 這支 API 的說明相當簡略,只告訴你這個 API 可以拿來判斷某個 key 是不是被強制,卻沒有把整個 preference 搜尋順序裡各層級的先後關係講清楚。網路上能找到的資訊也大多是憑印象轉述,沒有人真的擺出證據。
與其對著文件猜,不如自己寫一支工具,實際測給自己看。以下這篇的測試環境是 macOS Tahoe 26.6.1,Apple Silicon 機器。
先想清楚要驗證什麼
要判斷一個 key 的最終值是被誰決定的,其實只需要同時攤開四樣東西比對:
1. CFPreferencesAppValueIsForced 的結果,也就是這個 key 是不是被判定為 Managed
2. CFPreferencesCopyAppValue 解析出來的值,也就是系統實際上會拿去用的值
3. 使用者層級 plist(~/Library/Preferences/<domain>.plist)裡的原始值
4. 系統層級 plist(/Library/Preferences/<domain>.plist)裡的原始值
把這四樣東西擺在一起看,就能一眼看出哪個值贏了。於是我用 Xcode 建了一個最陽春的 macOS command line tool 專案,取名叫 Prefcli,吃兩個參數(domain 跟 key),把上面四項印出來:
import Foundation
func printUsageAndExit() -> Never {
let toolName = (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "Prefcli"
print("用法: \(toolName) <preference-domain> <key>")
exit(1)
}
guard CommandLine.arguments.count == 3 else {
printUsageAndExit()
}
let domain = CommandLine.arguments[1]
let key = CommandLine.arguments[2]
func describe(_ value: Any?) -> String {
guard let value = value else { return "(不存在 / nil)" }
return "\(value) <型別: \(type(of: value))>"
}
func readRawPlistValue(atPath path: String, key: String) -> Any? {
guard FileManager.default.fileExists(atPath: path),
let data = FileManager.default.contents(atPath: path) else {
return nil
}
guard let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] else {
return nil
}
return plist[key]
}
let isForced = CFPreferencesAppValueIsForced(key as CFString, domain as CFString)
let resolvedValue = CFPreferencesCopyAppValue(key as CFString, domain as CFString) as Any?
let userPlistPath = NSString(string: "~/Library/Preferences/\(domain).plist").expandingTildeInPath
let userRawValue = readRawPlistValue(atPath: userPlistPath, key: key)
let systemPlistPath = "/Library/Preferences/\(domain).plist"
let systemRawValue = readRawPlistValue(atPath: systemPlistPath, key: key)
print("[1] CFPreferencesAppValueIsForced: \(isForced)")
print("[2] CFPreferencesCopyAppValue: \(describe(resolvedValue))")
print("[3] 使用者層級原始值: \(describe(userRawValue))")
print("[4] 系統層級原始值: \(describe(systemRawValue))")這支工具沒有處理任何錯誤情境,也沒有寫測試,純粹是為了肉眼比對而存在,能跑出結果就夠了。
第一輪:使用者層級對上系統層級
工具寫好之後,先用一個自訂的測試 domain,不去動任何真實的系統元件。第一步用一般權限寫入一個值:
defaults write com.prefcli.test TestKey UserLevelValue再用 sudo 寫進系統層級:
sudo defaults write /Library/Preferences/com.prefcli.test TestKey SystemLevelValue跑一次 Prefcli,結果是:
[2] CFPreferencesCopyAppValue: UserLevelValue
[3] 使用者層級原始值: UserLevelValue
[4] 系統層級原始值: SystemLevelValue
兩個層級都確實寫入了各自的值,但系統實際採用的還是使用者層級那份。也就是說,就算你是管理員,用 sudo 蓋掉系統層級的 plist,只要使用者自己也動過同一個 key,使用者層級還是贏。
第二輪:想找一台真的被 MDM 管的機器來測
自己的開發機沒辦法真的模擬 MDM 環境,於是準備了一台獨立的測試機,用 Jamf Pro 打政策上去。原本打算走 Apple 這幾年主推的 DDM(Declarative Device Management),透過 Jamf 的 Blueprint 推一個 declaration,把同一個 domain/key 標成 Forced。
結果卡關了。去翻了 Apple 公開在 GitHub 上的 DDM schema 定義才發現 com.apple.configuration.legacy 的 Payload 就只有一個欄位叫 ProfileURL,而且官方寫得很白:
The URL of the profile to download and install, which needs to start with `https://`, and must be hosted by the MDM server.
意思是這個 declaration 期待的是一個「由 MDM server 自己代管」的網址,裝置會自己去下載,並不支援把 profile 內容直接寫死在 declaration 裡面。所以後來還是改回用 Jamf Pro 裡的 Configuration Profiles 本來就有一個叫 Application & Custom Settings 的 payload,專門就是為了推送任意 preference domain 而存在的:填一個 domain,貼一份 property list,Jamf 自己會把它包成 com.apple.ManagedClient.preferences 的 Forced payload,簽好章、走傳統 MDM 協定推下去。不用處理 URL 代管,也不用處理簽章問題。
Preference Domain: com.prefcli.test
<?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>TestKey</key>
<string>ManagedForcedValue</string>
</dict>
</plist>部署到測試機之後,SSH 進去跑 Prefcli:
[1] CFPreferencesAppValueIsForced: true
[2] CFPreferencesCopyAppValue: ManagedForcedValue
[3] 使用者層級原始值: (不存在 / nil)
[4] 系統層級原始值: (不存在 / nil)IsForced 正確回報 true,Managed 的值也確實生效了。
三個層級同時擺在一起,才是真正的驗證
只測 Managed 單獨存在的情況還不夠有說服力,畢竟這時候使用者層級跟系統層級根本沒有值可以競爭。想要真的驗證優先權,就得讓三種來源同時存在同一個 key 上。於是在測試機上,補寫了使用者層級跟系統層級的值:
defaults write com.prefcli.test TestKey UserLevelValue
sudo defaults write /Library/Preferences/com.prefcli.test TestKey SystemLevelValue這時候三種值同時掛在同一個 key 底下,再跑一次 Prefcli:
[1] CFPreferencesAppValueIsForced: true
[2] CFPreferencesCopyAppValue: ManagedForcedValue
[3] 使用者層級原始值: UserLevelValue
[4] 系統層級原始值: SystemLevelValue使用者層級跟系統層級的值都確實寫進去了,CFPreferencesCopyAppValue 拿到的還是 Managed 的那個值。也就是說,Managed(Forced) 完全無視前面兩者,不管使用者或管理員怎麼改本機設定,只要 MDM 標成 Forced,系統一律採用 MDM 給的值。
小結
整理起來,這幾層的優先權關係是:
Managed (Forced, current user)
> Managed (Forced, any user)
> ~/Library/Preferences (使用者層級)
> /Library/Preferences (系統層級)
> Registration domain (程式內建預設值)這對做 MDM 政策設計的人來說其實蠻實際的:如果你把某個 key 標成 Forced,就不用再擔心使用者自己改設定、或是某個舊的部署腳本殘留的系統層級設定會蓋過去,MDM 的值永遠贏。反過來說,如果你發現某個 Forced 的政策看起來沒生效,那問題大概不會出在優先權上,而是要往別的地方找了,比如 profile 有沒有真的推送成功、payload 裡的 domain 跟 key 名稱是不是打錯了。