class Chef::Provider::User::MacUser
A macOS user provider that is compatible with default TCC restrictions in macOS 10.14+. See resource/user/mac_user.rb for complete description of the mac_user resource
Attributes
admin_group_plist[R]
user_plist[R]
Public Instance Methods
admin_user?()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 507 def admin_user? admin_group_plist[:group_members].any? { |mem| mem == user_plist[:guid][0] } rescue false end
check_lock()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 347 def check_lock @locked = locked? end
compare_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 222 def compare_user @change_desc = [] %i{comment shell uid gid salt password admin secure_token hidden}.each do |attr| if diverged?(attr) desc = "Update #{attr}" unless %i{password gid secure_token hidden}.include?(attr) desc << " from #{current_resource.send(attr)} to #{new_resource.send(attr)}" end @change_desc << desc end end !@change_desc.empty? end
convert_to_binary(string)
click to toggle source
# File lib/chef/provider/user/mac.rb, line 513 def convert_to_binary(string) string.unpack("a2" * (string.size / 2)).collect { |i| i.hex.chr }.join end
create_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 145 def create_user cmd = [-"-addUser", new_resource.username] cmd += ["-fullName", new_resource.comment] if prop_is_set?(:comment) cmd += ["-UID", prop_is_set?(:uid) ? new_resource.uid : get_free_uid] cmd += ["-shell", new_resource.shell] cmd += ["-home", new_resource.home] cmd += ["-admin"] if new_resource.admin # We can technically create a new user without the admin credentials # but without them the user cannot enable SecureToken, thus they cannot # create other secure users or enable FileVault full disk encryption. if prop_is_set?(:admin_username) && prop_is_set?(:admin_password) cmd += ["-adminUser", new_resource.admin_username] cmd += ["-adminPassword", new_resource.admin_password] end # sysadminctl doesn't exit with a non-zero exit code if it encounters # a problem. We'll check stderr and make sure we see that it finished # correctly. res = run_sysadminctl(cmd) unless /creating user/.match?(res.downcase) raise Chef::Exceptions::User, "error when creating user: #{res}" end # Wait for the user to show up in the ds cache wait_for_user # Reload with up-to-date user information reload_user_plist reload_admin_group_plist if prop_is_set?(:hidden) set_hidden end if prop_is_set?(:password) converge_by("set password") { set_password } end if new_resource.manage_home # "sysadminctl -addUser" will create the home directory if it's # the default /Users/<username>, otherwise it sets it in plist # but does not create it. Here we'll ensure that it gets created # if we've been given a directory that is not the default. unless ::File.directory?(new_resource.home) && ::File.exist?(new_resource.home) converge_by("create home directory") do shell_out!("createhomedir -c -u #{new_resource.username}") end end end if prop_is_set?(:gid) # NOTE: Here we're managing the primary group of the user which is # a departure from previous behavior. We could just set the # PrimaryGroupID for the user and move on if we decide that actual # group management should be done outside of the core resource. group_name, group_id, group_action = user_group_info group group_name do members new_resource.username gid group_id if group_id action group_action append true end converge_by("create primary group ID") do run_dscl("create", "/Users/#{new_resource.username}", "PrimaryGroupID", group_id) end end if diverged?(:secure_token) converge_by("alter SecureToken") { toggle_secure_token } end reload_user_plist end
diverged?(prop)
click to toggle source
Methods
# File lib/chef/provider/user/mac.rb, line 355 def diverged?(prop) prop = prop.to_sym case prop when :password password_diverged? when :gid user_group_diverged? when :secure_token secure_token_diverged? when :hidden hidden_diverged? else # Other fields are have been set on current resource so just compare # them. !new_resource.send(prop).nil? && (new_resource.send(prop) != current_resource.send(prop)) end end
get_free_uid(search_limit = 1000)
click to toggle source
Find the next available uid on the system. Starting with 200 if `system` is set, 501 otherwise.
# File lib/chef/provider/user/mac.rb, line 376 def get_free_uid(search_limit = 1000) uid = nil base_uid = new_resource.system ? 200 : 501 next_uid_guess = base_uid users_uids = run_dscl("list", "/Users", "uid") while next_uid_guess < search_limit + base_uid if users_uids&.match?(Regexp.new("#{Regexp.escape(next_uid_guess.to_s)}\n")) next_uid_guess += 1 else uid = next_uid_guess break end end uid || raise("uid not found. Exhausted. Searched #{search_limit} times") end
load_current_resource()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 41 def load_current_resource @current_resource = Chef::Resource::User::MacUser.new(new_resource.username) current_resource.username(new_resource.username) reload_admin_group_plist reload_user_plist if user_plist current_resource.uid(user_plist[:uid][0]) current_resource.gid(user_plist[:gid][0]) current_resource.home(user_plist[:home][0]) current_resource.shell(user_plist[:shell]&.first) # use &.first since shell can be nil current_resource.comment(user_plist[:comment][0]) if user_plist[:is_hidden] current_resource.hidden(user_plist[:is_hidden]&.first == "1" ? true : false) # when not hidden the value seems to be nil so &.first to handle that end shadow_hash = user_plist[:shadow_hash] if shadow_hash current_resource.password(shadow_hash[0]["SALTED-SHA512-PBKDF2"]["entropy"].string.unpack("H*")[0]) current_resource.salt(shadow_hash[0]["SALTED-SHA512-PBKDF2"]["salt"].string.unpack("H*")[0]) current_resource.iterations(shadow_hash[0]["SALTED-SHA512-PBKDF2"]["iterations"].to_i) end current_resource.secure_token(secure_token_enabled?) current_resource.admin(admin_user?) else @user_exists = false logger.trace("#{new_resource} user does not exist") end current_resource end
lock_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 327 def lock_user run_dscl("append", "/Users/#{new_resource.username}", "AuthenticationAuthority", ";DisabledUser;") reload_user_plist end
locked?()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 341 def locked? user_plist[:auth_authority].any? { |tag| tag == ";DisabledUser;" } rescue false end
manage_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 236 def manage_user %i{uid home}.each do |prop| raise Chef::Exceptions::User, "cannot modify #{prop} on macOS >= 10.14" if diverged?(prop) end if diverged?(:password) converge_by("alter password") { set_password } end if diverged?(:comment) converge_by("alter comment") do run_dscl("create", "/Users/#{new_resource.username}", "RealName", new_resource.comment) end end if diverged?(:shell) converge_by("alter shell") do run_dscl("create", "/Users/#{new_resource.username}", "UserShell", new_resource.shell) end end if diverged?(:secure_token) converge_by("alter SecureToken") { toggle_secure_token } end if diverged?(:admin) converge_by("alter admin group membership") do group "admin" do if new_resource.admin members new_resource.username else excluded_members new_resource.username end action :create append true end admins = admin_group_plist[:group_members] if new_resource.admin admins << user_plist[:guid][0] else admins.reject! { |m| m == user_plist[:guid][0] } end run_dscl("create", "/Groups/admin", "GroupMembers", admins) end reload_admin_group_plist end group_name, group_id, group_action = user_group_info group group_name do gid group_id if group_id members new_resource.username action group_action append true end if diverged?(:gid) converge_by("alter group membership") do run_dscl("create", "/Users/#{new_resource.username}", "PrimaryGroupID", group_id) end end if diverged?(:hidden) converge_by("alter hidden") { set_hidden } end reload_user_plist end
password_diverged?()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 474 def password_diverged? # There are three options for configuring the password: # * ShadowHashData which includes the hash data as: # * hashed entropy as the "password" # * salt # * iterations # * Plaintext password # * Not configuring it # Check for no desired password configuration return false unless prop_is_set?(:password) # Check for ShadowHashData divergence by comparing the entropy, # salt, and iterations. if prop_is_set?(:salt) return true if %i{salt iterations}.any? { |prop| diverged?(prop) } return new_resource.password != current_resource.password end # Check for plaintext password divergence. We don't actually know # what the stored password is but we can hash the given password with # stored salt and iterations, and compare the resulting entropy with # the saved entropy. OpenSSL::PKCS5.pbkdf2_hmac( new_resource.password, convert_to_binary(current_resource.salt), current_resource.iterations.to_i, 128, OpenSSL::Digest.new("SHA512") ).unpack("H*")[0] != current_resource.password end
prop_is_set?(prop)
click to toggle source
# File lib/chef/provider/user/mac.rb, line 631 def prop_is_set?(prop) v = new_resource.send(prop.to_sym) !v.nil? && v != "" end
reload_admin_group_plist()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 76 def reload_admin_group_plist @admin_group_plist = nil admin_group_xml = run_dscl("read", "/Groups/admin") return nil unless admin_group_xml && admin_group_xml != "" @admin_group_plist = Plist.new(::Plist.parse_xml(admin_group_xml)) end
reload_user_plist()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 85 def reload_user_plist @user_plist = nil # Load the user information. begin user_xml = run_dscl("read", "/Users/#{new_resource.username}") rescue Chef::Exceptions::DsclCommandFailed return nil end return nil if user_xml.nil? || user_xml == "" @user_plist = Plist.new(::Plist.parse_xml(user_xml)) return unless user_plist[:shadow_hash] shadow_hash_hex = user_plist[:shadow_hash][0] return unless shadow_hash_hex && shadow_hash_hex != "" # The password information is stored in the ShadowHashData key in the # plist. However, parsing it is a bit tricky as the value is itself # another encoded binary plist. We have to extract the encoded plist, # decode it from hex to a binary plist and then convert the binary # into XML plist. From there we can extract the hash data. # # NOTE: `dscl -read` and `plutil -convert` return different values for # ShadowHashData. # # `dscl` returns the value encoded as a hex string and stored as a <string> # `plutil` returns the value encoded as a base64 string stored as <data> # # eg: # # spellchecker: disable # # <array> # <string>77687920 63616e27 74206170 706c6520 6275696c 6420636f 6e736973 74656e74 20746f6f 6c696e67</string> # </array> # # vs # # <array> # <data>AADKAAAKAA4LAA0MAAAAAAAAAAA=</data> # </array> # # spellchecker: disable # begin shadow_binary_plist = [shadow_hash_hex.delete(" ")].pack("H*") shadow_xml_plist = shell_out("plutil", "-convert", "xml1", "-o", "-", "-", input: shadow_binary_plist).stdout user_plist[:shadow_hash] = ::Plist.parse_xml(shadow_xml_plist) rescue Chef::Exceptions::PlistUtilCommandFailed, Chef::Exceptions::DsclCommandFailed nil end end
remove_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 308 def remove_user cmd = ["-deleteUser", new_resource.username] cmd << new_resource.manage_home ? "-secure" : "-keepHome" if %i{admin_username admin_password}.all? { |p| prop_is_set?(p) } cmd += ["-adminUser", new_resource.admin_username] cmd += ["-adminPassword", new_resource.admin_password] end # sysadminctl doesn't exit with a non-zero exit code if it encounters # a problem. We'll check stderr and make sure we see that it finished res = run_sysadminctl(cmd) unless /deleting record|not found/.match?(res.downcase) raise Chef::Exceptions::User, "error deleting user: #{res}" end reload_user_plist @user_exists = false end
run_dscl(*args)
click to toggle source
# File lib/chef/provider/user/mac.rb, line 615 def run_dscl(*args) result = shell_out("dscl", "-plist", ".", "-#{args[0]}", args[1..]) return "" if ( args.first =~ /^delete/ ) && ( result.exitstatus != 0 ) raise(Chef::Exceptions::DsclCommandFailed, "dscl error: #{result.inspect}") unless result.exitstatus == 0 raise(Chef::Exceptions::DsclCommandFailed, "dscl error: #{result.inspect}") if /No such key: /.match?(result.stdout) result.stdout end
run_dsimport(*args)
click to toggle source
# File lib/chef/provider/user/mac.rb, line 604 def run_dsimport(*args) shell_out!("dsimport", args) end
run_plutil(*args)
click to toggle source
# File lib/chef/provider/user/mac.rb, line 624 def run_plutil(*args) result = shell_out("plutil", "-#{args[0]}", args[1..]) raise(Chef::Exceptions::PlistUtilCommandFailed, "plutil error: #{result.inspect}") unless result.exitstatus == 0 result.stdout end
run_sysadminctl(args)
click to toggle source
# File lib/chef/provider/user/mac.rb, line 608 def run_sysadminctl(args) # sysadminctl doesn't exit with a non-zero code when errors are encountered # and outputs everything to STDERR instead of STDOUT and STDERR. Therefore we'll # return the STDERR and let the caller handle it. shell_out!("sysadminctl", args).stderr end
secure_token_diverged?()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 419 def secure_token_diverged? new_resource.secure_token ? !secure_token_enabled? : secure_token_enabled? end
secure_token_enabled?()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 413 def secure_token_enabled? user_plist[:auth_authority].any? { |tag| tag == ";SecureToken;" } rescue false end
set_password()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 517 def set_password if prop_is_set?(:salt) entropy = StringIO.new(convert_to_binary(new_resource.password)) salt = StringIO.new(convert_to_binary(new_resource.salt)) else salt = StringIO.new(OpenSSL::Random.random_bytes(32)) entropy = StringIO.new( OpenSSL::PKCS5.pbkdf2_hmac( new_resource.password, salt.string, new_resource.iterations, 128, OpenSSL::Digest.new("SHA512") ) ) end shadow_hash = user_plist[:shadow_hash] ? user_plist[:shadow_hash][0] : {} shadow_hash["SALTED-SHA512-PBKDF2"] = { "entropy" => entropy, "salt" => salt, "iterations" => new_resource.iterations, } shadow_hash_binary = StringIO.new shell_out("plutil", "-convert", "binary1", "-o", "-", "-", input: shadow_hash.to_plist, live_stream: shadow_hash_binary) # Apple seem to have killed their dsimport documentation about the # dsimport record format. Perhaps that means our days of being able to # use dsimport without an admin password or perhaps at all could be # numbered. Here is the record format for posterity: # # End of record character # Escape character # Field separator # Value separator # Record type (Users, Groups, Computers, ComputerGroups, ComputerLists) # Number of properties # Property 1 # ... # Property N # # The user password shadow data format breaks down as: # # 0x0A End of record denoted by \n # 0x5C Escaping is denoted by \ # 0x3A Fields are separated by : # 0x2C Values are separated by , # dsRecTypeStandard:Users The record type we're configuring # 2 How many properties we're going to set # dsAttrTypeStandard:RecordName Property 1: our users record name # base64:dsAttrTypeNative:ShadowHashData Property 2: our shadow hash data import_file = ::File.join(Chef::Config["file_cache_path"], "#{new_resource.username}_password_dsimport") ::File.open(import_file, "w+", 0600) do |f| f.write <<~DSIMPORT 0x0A 0x5C 0x3A 0x2C dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName base64:dsAttrTypeNative:ShadowHashData #{new_resource.username}:#{::Base64.strict_encode64(shadow_hash_binary.string)} DSIMPORT end run_dscl("delete", "/Users/#{new_resource.username}", "ShadowHashData") run_dsimport(import_file, "/Local/Default", "M") run_dscl("create", "/Users/#{new_resource.username}", "Password", "********") ensure ::File.delete(import_file) if import_file && ::File.exist?(import_file) end
toggle_secure_token()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 423 def toggle_secure_token # Check for this lazily as we only need to validate for these credentials # if we're toggling secure token. unless %i{admin_username admin_password secure_token_password}.all? { |p| prop_is_set?(p) } raise Chef::Exceptions::User, "secure_token_password, admin_username and admin_password properties are required to modify SecureToken" end cmd = (new_resource.secure_token ? %w{-secureTokenOn} : %w{-secureTokenOff}) cmd += [new_resource.username, "-password", new_resource.secure_token_password] cmd += ["-adminUser", new_resource.admin_username] cmd += ["-adminPassword", new_resource.admin_password] # sysadminctl doesn't exit with a non-zero exit code if it encounters # a problem. We'll check stderr and make sure we see that it finished res = run_sysadminctl(cmd) unless /done/.match?(res.downcase) raise Chef::Exceptions::User, "error when modifying SecureToken: #{res}" end # HACK: When SecureToken is enabled or disabled it requires the user # password in plaintext, which it verifies and uses as a key. It also # takes the liberty of _rehashing_ the password with a random salt and # iterations count and saves it back into the user ShadowHashData. # # Therefore, if we're configuring a user based upon existing shadow # hash data we'll have to set the password again so that future runs # of the client don't show password drift. set_password if prop_is_set?(:salt) end
unlock_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 333 def unlock_user auth_string = user_plist[:auth_authority].reject! { |tag| tag == ";DisabledUser;" }.join.strip run_dscl("create", "/Users/#{new_resource.username}", "AuthenticationAuthority", auth_string) reload_user_plist end
user_group_diverged?()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 453 def user_group_diverged? return false unless prop_is_set?(:gid) group_name, group_id = user_group_info current_resource.gid != group_id.to_i end
user_group_info()
click to toggle source
Attempt to resolve the group name, gid, and the action required for associated group resource. If a group exists we'll modify it, otherwise create it.
# File lib/chef/provider/user/mac.rb, line 395 def user_group_info @user_group_info ||= if new_resource.gid.is_a?(String) begin g = Etc.getgrnam(new_resource.gid) [g.name, g.gid.to_s, :modify] rescue [new_resource.gid, nil, :create] end else begin g = Etc.getgrgid(new_resource.gid) [g.name, g.gid.to_s, :modify] rescue [g.username, nil, :create] end end end
wait_for_user()
click to toggle source
# File lib/chef/provider/user/mac.rb, line 587 def wait_for_user timeout = Time.now + 5 loop do run_dscl("read", "/Users/#{new_resource.username}", "ShadowHashData") break rescue Chef::Exceptions::DsclCommandFailed => e if Time.now < timeout sleep 0.1 else raise Chef::Exceptions::User, e.message end end end