diff options
-rw-r--r-- | lib/chef/provider/user/dscl.rb | 4 | ||||
-rw-r--r-- | lib/chef/provider/user/mac.rb | 628 | ||||
-rw-r--r-- | lib/chef/providers.rb | 1 | ||||
-rw-r--r-- | lib/chef/resource/user.rb | 1 | ||||
-rw-r--r-- | lib/chef/resource/user/dscl_user.rb | 2 | ||||
-rw-r--r-- | lib/chef/resource/user/mac_user.rb | 119 | ||||
-rw-r--r-- | lib/chef/resources.rb | 1 | ||||
-rw-r--r-- | spec/functional/resource/user/mac_user_spec.rb | 188 | ||||
-rw-r--r-- | spec/spec_helper.rb | 1 | ||||
-rw-r--r-- | spec/support/platform_helpers.rb | 10 | ||||
-rw-r--r-- | spec/unit/provider/user/dscl_spec.rb | 1 | ||||
-rw-r--r-- | spec/unit/provider/user/mac_spec.rb | 38 |
12 files changed, 991 insertions, 3 deletions
diff --git a/lib/chef/provider/user/dscl.rb b/lib/chef/provider/user/dscl.rb index b8f85618da..027f9eba38 100644 --- a/lib/chef/provider/user/dscl.rb +++ b/lib/chef/provider/user/dscl.rb @@ -42,7 +42,7 @@ class Chef # => shadow binary length 128 bytes # => Salt / Iterations are stored separately in the same file # - # This provider only supports Mac OSX versions 10.7 and above + # This provider only supports macOS versions 10.7 to 10.13 class Dscl < Chef::Provider::User attr_accessor :user_info @@ -50,7 +50,7 @@ class Chef attr_accessor :password_shadow_conversion_algorithm provides :dscl_user - provides :user, os: "darwin" + provides :user, os: "darwin", platform_version: "<= 10.13" # Just-in-case a recipe calls the user dscl provider without specifying # a gid property. Avoids chown issues in move_home when the manage_home diff --git a/lib/chef/provider/user/mac.rb b/lib/chef/provider/user/mac.rb new file mode 100644 index 0000000000..414445cfa1 --- /dev/null +++ b/lib/chef/provider/user/mac.rb @@ -0,0 +1,628 @@ +# +# Author:: Ryan Cragun (<ryan@chef.io>) +# Copyright:: Copyright (c) 2019, Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require_relative "../../resource" +require_relative "../../dsl/declare_resource" +require_relative "../../mixin/shell_out" +require_relative "../../mixin/which" +require_relative "../user" +require_relative "../../resource/user/mac_user" + +class Chef + class Provider + class User + # 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 and how it differs from the dscl resource used + # on previous platforms. + class MacUser < Chef::Provider::User + include Chef::Mixin::Which + + provides :mac_user + provides :user, os: "darwin", platform_version: ">= 10.14" + + attr_reader :user_plist, :admin_group_plist + + 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][0]) + current_resource.comment(user_plist[:comment][0]) + + 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 + + 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 + + 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)) + + shadow_hash_hex = user_plist[:shadow_hash][0] + return unless shadow_hash_hex && shadow_hash_hex != "" + + # The password infomation 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: + # + # <array> + # <string>77687920 63616e27 74206170 706c6520 6275696c 6420636f 6e736973 74656e74 20746f6f 6c696e67</string> + # </array> + # + # vs + # + # <array> + # <data>AADKAAAKAA4LAA0MAAAAAAAAAAA=</data> + # </array> + # + 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 + + # + # User Provider Callbacks + # + + def create_user + cmd = [-"-addUser", new_resource.username] + cmd += ["-fullName", new_resource.comment] if new_resource.property_is_set?(:comment) + cmd += ["-UID", new_resource.uid] if new_resource.property_is_set?(: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 new_resource.property_is_set?(:admin_username) && new_resource.property_is_set?(:admin_password) + cmd += ["-adminUser", new_resource.admin_username] + cmd += ["-adminPassword", new_resource.admin_password] + end + + converge_by "create user" do + # 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 res.downcase =~ /creating user/ + raise Chef::Exceptions::User, "error when creating user: #{res}" + end + 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 new_resource.property_is_set?(:password) + converge_by("set password") { set_password } + end + + if new_resource.manage_home + # "sydadminctl -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 new_resource.property_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 magement should be done outside of the core resource. + group_name, group_id, group_action = user_group_info + + declare_resource(:group, group_name) do + members new_resource.username + gid group_id if group_id + action :nothing + append true + end.run_action(group_action) + + converge_by("create primary group ID") do + run_dscl("create", "/Users/#{new_resource.username}", "PrimaryGroupID", new_resource.gid) + end + end + + if diverged?(:secure_token) + converge_by("alter SecureToken") { toggle_secure_token } + end + + reload_user_plist + end + + def compare_user + %i{comment shell uid gid salt password admin secure_token}.any? { |m| diverged?(m) } + end + + 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 + declare_resource(:group, "admin") do + if new_resource.admin + members new_resource.username + else + excluded_members new_resource.username + end + + action :nothing + append true + end.run_action(:create) + + 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 + declare_resource(:group, group_name) do + gid group_id if group_id + members new_resource.username + action :nothing + append true + end.run_action(group_action) + + if diverged?(:gid) + converge_by("alter group membership") do + run_dscl("create", "/Users/#{new_resource.username}", "PrimaryGroupID", new_resource.gid) + end + end + + reload_user_plist + end + + def remove_user + cmd = ["-deleteUser", new_resource.username] + cmd << new_resource.manage_home ? "-secure" : "-keepHome" + if new_resource.property_is_set?(:admin_username) && new_resource.property_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 + converge_by "remove user" do + res = run_sysadminctl(cmd) + unless res.downcase =~ /deleting record|not found/ + raise Chef::Exceptions::User, "error deleting user: #{res}" + end + end + + reload_user_plist + @user_exists = false + end + + def lock_user + converge_by "lock user" do + run_dscl("append", "/Users/#{new_resource.username}", "AuthenticationAuthority", ";DisabledUser;") + end + + reload_user_plist + end + + def unlock_user + auth_string = user_plist[:auth_authority].reject! { |tag| tag == ";DisabledUser;" }.join.strip + converge_by "unlock user" do + run_dscl("create", "/Users/#{new_resource.username}", "AuthenticationAuthority", auth_string) + end + + reload_user_plist + end + + def locked? + user_plist[:auth_authority].any? { |tag| tag == ";DisabledUser;" } + rescue + false + end + + def check_lock + @locked = locked? + end + + # + # Methods + # + + def diverged?(prop) + prop = prop.to_sym + + case prop + when :password + password_diverged? + when :gid + user_group_diverged? + when :secure_token + secure_token_diverged? + else + # Other fields are have been set on current resource so just compare + # them. + new_resource.property_is_set?(prop) && (new_resource.send(prop) != current_resource.send(prop)) + end + end + + # 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. + def user_group_info + @user_group_info ||= begin + 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 + end + + def secure_token_enabled? + user_plist[:auth_authority].any? { |tag| tag == ";SecureToken;" } + rescue + false + end + + def secure_token_diverged? + new_resource.secure_token ? !secure_token_enabled? : secure_token_enabled? + end + + def toggle_secure_token + # Check for this lazily as we only need to validate for these credentials + # if we're toggling secure token. + unless new_resource.property_is_set?(:admin_username) && + new_resource.property_is_set?(:admin_password) && + # property_is_set? can't handle a default inherited from password + # when not using shadow hash data. Hence, we'll just have to + # make sure some valid string is there. + new_resource.secure_token_password && + new_resource.secure_token_password != "" + raise Chef::Exceptions::User, "secure_token_password, admin_user 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 res.downcase =~ /done/ + 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 new_resource.property_is_set?(:salt) + end + + def user_group_diverged? + return false unless new_resource.property_is_set?(:gid) + + group_name, group_id = user_group_info + + if current_resource.gid.is_a?(String) + current_resource.gid != group_name + else + current_resource.gid != group_id.to_i + end + end + + 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 new_resource.property_is_set?(:password) + + # Check for ShadowHashData divergence by comparing the entropy, + # salt, and iterations. + if new_resource.property_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::SHA512.new + ).unpack("H*")[0] != current_resource.password + end + + def admin_user? + admin_group_plist[:group_members].any? { |mem| mem == user_plist[:guid][0] } + rescue + false + end + + def convert_to_binary(string) + string.unpack("a2" * (string.size / 2)).collect { |i| i.hex.chr }.join + end + + def set_password + if new_resource.property_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::SHA512.new + ) + ) + end + + 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 seperated 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 defined?(import_file) && ::File.exist?(import_file) + end + + def wait_for_user + timeout = Time.now + 5 + + loop do + begin + 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 + end + + def run_dsimport(*args) + shell_out!("dsimport", args) + end + + def run_sysadminctl(args) + # sysadminctl doesn't exit with a non-zero code when errors are encountered + # and ouputs 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 + + def run_dscl(*args) + result = shell_out("dscl", "-plist", ".", "-#{args[0]}", args[1..-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 result.stdout =~ /No such key: / + + result.stdout + end + + def run_plutil(*args) + result = shell_out("plutil", "-#{args[0]}", args[1..-1]) + raise(Chef::Exceptions::PlistUtilCommandFailed, "plutil error: #{result.inspect}") unless result.exitstatus == 0 + + result.stdout + end + + class Plist + DSCL_PROPERTY_MAP = { + uid: "dsAttrTypeStandard:UniqueID", + guid: "dsAttrTypeStandard:GeneratedUID", + gid: "dsAttrTypeStandard:PrimaryGroupID", + home: "dsAttrTypeStandard:NFSHomeDirectory", + shell: "dsAttrTypeStandard:UserShell", + comment: "dsAttrTypeStandard:RealName", + password: "dsAttrTypeStandard:Password", + auth_authority: "dsAttrTypeStandard:AuthenticationAuthority", + shadow_hash: "dsAttrTypeNative:ShadowHashData", + group_members: "dsAttrTypeStandard:GroupMembers", + }.freeze + + attr_accessor :plist_hash, :property_map + + def initialize(plist_hash = {}, property_map = DSCL_PROPERTY_MAP) + @plist_hash = plist_hash + @property_map = property_map + end + + def get(key) + return nil unless property_map.key?(key) + + plist_hash[property_map[key]] + end + alias_method :[], :get + + def set(key, value) + return nil unless property_map.key?(key) + + plist_hash[property_map[key]] = [ value ] + end + alias_method :[]=, :set + + end + end + end + end +end diff --git a/lib/chef/providers.rb b/lib/chef/providers.rb index 85f2cddaf9..1e45ca13ed 100644 --- a/lib/chef/providers.rb +++ b/lib/chef/providers.rb @@ -107,6 +107,7 @@ require_relative "provider/service/aix" require_relative "provider/user/aix" require_relative "provider/user/dscl" require_relative "provider/user/linux" +require_relative "provider/user/mac" require_relative "provider/user/pw" require_relative "provider/user/solaris" require_relative "provider/user/windows" diff --git a/lib/chef/resource/user.rb b/lib/chef/resource/user.rb index 19ae2d8dc8..6ade20082f 100644 --- a/lib/chef/resource/user.rb +++ b/lib/chef/resource/user.rb @@ -47,6 +47,7 @@ class Chef property :password, String, description: "The password shadow hash", + sensitive: true, desired_state: false property :non_unique, [ TrueClass, FalseClass ], diff --git a/lib/chef/resource/user/dscl_user.rb b/lib/chef/resource/user/dscl_user.rb index 54d78d20a0..a3f6661c22 100644 --- a/lib/chef/resource/user/dscl_user.rb +++ b/lib/chef/resource/user/dscl_user.rb @@ -24,7 +24,7 @@ class Chef resource_name :dscl_user provides :dscl_user - provides :user, os: "darwin" + provides :user, os: "darwin", platform_version: "<= 10.13" property :iterations, Integer, description: "macOS platform only. The number of iterations for a password with a SALTED-SHA512-PBKDF2 shadow hash.", diff --git a/lib/chef/resource/user/mac_user.rb b/lib/chef/resource/user/mac_user.rb new file mode 100644 index 0000000000..ab5cc12947 --- /dev/null +++ b/lib/chef/resource/user/mac_user.rb @@ -0,0 +1,119 @@ +# +# Author:: Ryan Cragun (<ryan@chef.io>) +# Copyright:: Copyright 2019, Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require_relative "../user" + +class Chef + class Resource + class User + # Provide a user resource that is compatible with default TCC restrictions + # that were introduced in macOS 10.14. + # + # Changes: + # + # * This resource and the corresponding provider have been modified to + # work with default macOS TCC policies. Direct access to user binary + # plists are no longer permitted by default, thus we've chosen to use + # a combination of newer utilities for managing user lifecycles and older + # utilities for managing passwords. + # + # * Due to tooling changes that were necessitated by the new policy + # restrictions the mac_user resource is only suitable for use on macOS + # >= 10.14. Support for older platforms has been removed. + # + # New Features: + # + # * Primary group management is now included. + # + # * 'admin' is now a boolean property that configures a user to an admin. + # + # * 'admin_username' and 'admin_password' are new properties that define the + # admin user credentials required for toggling SecureToken for a user. + # + # The value of 'admin_username' must correspond to a system user that + # is part of the 'admin' with SecureToken enabled in order to toggle + # SecureToken. + # + # * 'secure_token' is a boolean property that sets the desired state + # for SecureToken. SecureToken token is required for FileVault full + # disk encryption. + # + # * 'secure_token_password' is the plaintext password required to enable + # or disable secure_token for a user. If no salt is specified we assume + # the 'password' property corresponds to a plaintext password and will + # attempt to use it in place of secure_token_password if it not set. + class MacUser < Chef::Resource::User + resource_name :mac_user + + provides :mac_user + provides :user, os: "darwin", platform_version: ">= 10.14" + + introduced "15.3" + + property :iterations, Integer, + description: "The number of iterations for a password with a SALTED-SHA512-PBKDF2 shadow hash.", + default: 57803, desired_state: false + + # Overload gid to set our default gid to 20, the macOS "staff" group. + # We also allow a string group name here which we'll attempt to resolve + # or create in the provider. + property :gid, [Integer, String], description: "The numeric group identifier.", default: 20, coerce: ->(gid) do + begin + Integer(gid) # Try and coerce a group id string into an integer + rescue + gid # assume we have a group name + end + end + + # Overload the password so we can set a length requirements and update the + # description. + property :password, String, description: "The plain text user password", sensitive: true, coerce: ->(password) { + # It would be nice if this could be in callbacks but we need the context + # of the resource to get the salt property so we have to do it in coerce. + if salt && password !~ /^[[:xdigit:]]{256}$/ + raise Chef::Exceptions::User, "Password must be a SALTED-SHA512-PBKDF2 shadow hash entropy when a shadow hash salt is given" + end + + password + }, + callbacks: { + "Password length must be >= 4" => ->(password) { password.size >= 4 }, + } + + # Overload home so we set our default. + property :home, String, description: "The user home directory", default: lazy { "/Users/#{name}" } + + property :admin, [TrueClass, FalseClass], description: "Create the user as an admin", default: false + + # TCC on macOS >= 10.14 requires admin credentials of an Admin user that + # has SecureToken enabled in order to toggle SecureToken. + property :admin_username, String, description: "Admin username for superuser actions" + property :admin_password, String, description: "Admin password for superuser actions", sensitive: true + + property :secure_token, [TrueClass, FalseClass], description: "Enable SecureToken for the user", default: false + # In order to enable SecureToken for a user we require the plaintext password. + property :secure_token_password, String, description: "The plaintext password for enabling SecureToken", sensitive: true, default: lazy { + # In some cases the user can pass the plaintext value to "password" instead of + # SALTED-SHA512-PBKDF2 entropy. In those cases we'll default to the + # same value. + (salt.nil? && password) ? password : nil + } + end + end + end +end diff --git a/lib/chef/resources.rb b/lib/chef/resources.rb index d76f1bbf06..235c1e41a1 100644 --- a/lib/chef/resources.rb +++ b/lib/chef/resources.rb @@ -116,6 +116,7 @@ require_relative "resource/user" require_relative "resource/user/aix_user" require_relative "resource/user/dscl_user" require_relative "resource/user/linux_user" +require_relative "resource/user/mac_user" require_relative "resource/user/pw_user" require_relative "resource/user/solaris_user" require_relative "resource/user/windows_user" diff --git a/spec/functional/resource/user/mac_user_spec.rb b/spec/functional/resource/user/mac_user_spec.rb new file mode 100644 index 0000000000..d01b08616e --- /dev/null +++ b/spec/functional/resource/user/mac_user_spec.rb @@ -0,0 +1,188 @@ +# +# Copyright:: Copyright 2019, Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "spec_helper" +require "chef/mixin/shell_out" + +metadata = { + macos_1014: true, + requires_root: true, +} + +describe "Chef::Resource::User with Chef::Provider::User::MacUser provider", metadata do + include Chef::Mixin::ShellOut + + def clean_user + shell_out!("/usr/bin/dscl . -delete '/Users/#{username}'") + rescue Mixlib::ShellOut::ShellCommandFailed + # Raised when the user is already cleaned + end + + def user_should_exist + expect(shell_out("/usr/bin/dscl . -read /Users/#{username}").error?).to be(false) + end + + def check_password(pass) + # In order to test the password we use dscl passwd command since + # that's the only command that gets the user password from CLI. + expect(shell_out("dscl . -passwd /Users/greatchef #{pass} new_password").exitstatus).to eq(0) + # Now reset the password back + expect(shell_out("dscl . -passwd /Users/greatchef new_password #{pass}").exitstatus).to eq(0) + end + + let(:node) do + n = Chef::Node.new + n.consume_external_attrs(OHAI_SYSTEM.data.dup, {}) + n + end + + let(:events) do + Chef::EventDispatch::Dispatcher.new + end + + let(:run_context) do + Chef::RunContext.new(node, {}, events) + end + + let(:username) do + "greatchef" + end + + let(:uid) { nil } + let(:gid) { 20 } + let(:home) { nil } + let(:manage_home) { false } + let(:password) { "XXXYYYZZZ" } + let(:comment) { "Great Chef" } + let(:shell) { "/bin/bash" } + let(:salt) { nil } + let(:iterations) { nil } + + let(:user_resource) do + r = Chef::Resource::User::MacUser.new("TEST USER RESOURCE", run_context) + r.username(username) + r.uid(uid) + r.gid(gid) + r.home(home) + r.shell(shell) + r.comment(comment) + r.manage_home(manage_home) + r.password(password) + r.salt(salt) + r.iterations(iterations) + r + end + + before do + clean_user + end + + after(:each) do + clean_user + end + + describe "action :create" do + it "should create the user" do + user_resource.run_action(:create) + user_should_exist + check_password(password) + end + end + + describe "when user exists" do + before do + existing_resource = user_resource.dup + existing_resource.run_action(:create) + user_should_exist + end + + describe "when password is updated" do + it "should update the password of the user" do + user_resource.password("mykitchen") + user_resource.run_action(:create) + check_password("mykitchen") + end + end + end + + describe "when password is being set via shadow hash" do + let(:password) do + "c734b6e4787c3727bb35e29fdd92b97c\ +1de12df509577a045728255ec7c6c5f5\ +c18efa05ed02b682ffa7ebc05119900e\ +b1d4880833aa7a190afc13e2bf0936b8\ +20123e8c98f0f9bcac2a629d9163caac\ +9464a8c234f3919082400b4f939bb77b\ +c5adbbac718b7eb99463a7b679571e0f\ +1c9fef2ef08d0b9e9c2bcf644eed2ffc" + end + + let(:iterations) { 25000 } + let(:salt) { "9e2e7d5ee473b496fd24cf0bbfcaedfcb291ee21740e570d1e917e874f8788ca" } + + it "action :create should create the user" do + user_resource.run_action(:create) + user_should_exist + check_password("soawesome") + end + + describe "when user exists" do + before do + existing_resource = user_resource.dup + existing_resource.run_action(:create) + user_should_exist + end + + describe "when password is updated" do + it "should update the password of the user" do + user_resource.password("mykitchen") + user_resource.run_action(:create) + check_password("mykitchen") + end + end + end + end + + describe "when a user is member of some groups" do + let(:groups) { %w{staff operator} } + + before do + existing_resource = user_resource.dup + existing_resource.run_action(:create) + + groups.each do |group| + shell_out!("/usr/bin/dscl . -append '/Groups/#{group}' GroupMembership #{username}") + end + end + + after do + groups.each do |group| + # Do not raise an error when user is correctly removed + shell_out("/usr/bin/dscl . -delete '/Groups/#{group}' GroupMembership #{username}") + end + end + + it ":remove action removes the user from the groups and deletes the user" do + user_resource.run_action(:remove) + groups.each do |group| + # Do not raise an error when group is empty + expect(shell_out("dscl . read /Groups/staff GroupMembership").stdout).not_to include(group) + end + end + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5ab97a8320..e19a06f3d7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -145,6 +145,7 @@ RSpec.configure do |config| config.filter_run_excluding not_supported_on_windows: true if windows? config.filter_run_excluding not_supported_on_macos: true if mac_osx? config.filter_run_excluding macos_only: true unless mac_osx? + config.filter_run_excluding macos_1014: true unless mac_osx_1014? config.filter_run_excluding not_supported_on_aix: true if aix? config.filter_run_excluding not_supported_on_solaris: true if solaris? config.filter_run_excluding not_supported_on_gce: true if gce? diff --git a/spec/support/platform_helpers.rb b/spec/support/platform_helpers.rb index 0b13169ac1..3ac657d84e 100644 --- a/spec/support/platform_helpers.rb +++ b/spec/support/platform_helpers.rb @@ -3,6 +3,7 @@ require "chef/mixin/shell_out" require "ohai/mixin/http_helper" require "ohai/mixin/gce_metadata" require "chef/mixin/powershell_out" +require "chef/version_class" class ShellHelpers extend Chef::Mixin::ShellOut @@ -110,6 +111,15 @@ def mac_osx_106? false end +def mac_osx_1014? + if mac_osx? + ver = Chef::Version.new(ohai[:platform_version]) + return ver.major == 10 && ver.minor == 14 + end + + false +end + def mac_osx? if File.exists? "/usr/bin/sw_vers" result = ShellHelpers.shell_out("/usr/bin/sw_vers") diff --git a/spec/unit/provider/user/dscl_spec.rb b/spec/unit/provider/user/dscl_spec.rb index b12ea78977..e20873dc92 100644 --- a/spec/unit/provider/user/dscl_spec.rb +++ b/spec/unit/provider/user/dscl_spec.rb @@ -35,6 +35,7 @@ describe Chef::Provider::User::Dscl do let(:node) do Chef::Node.new.tap do |node| node.automatic["os"] = "darwin" + node.automatic["platform_version"] = "10.13.0" end end diff --git a/spec/unit/provider/user/mac_spec.rb b/spec/unit/provider/user/mac_spec.rb new file mode 100644 index 0000000000..f7024f45c1 --- /dev/null +++ b/spec/unit/provider/user/mac_spec.rb @@ -0,0 +1,38 @@ +# +# Author:: Ryan Cragun (<ryan@chef.io>) +# Copyright:: Copyright (c) 2019 Chef Software, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "spec_helper" + +describe Chef::Provider::User::MacUser do + before do + allow(ChefConfig).to receive(:windows?) { false } + end + + let(:new_resource) { Chef::Resource::User::MacUser.new("jane") } + + let(:provider) do + node = Chef::Node.new + events = Chef::EventDispatch::Dispatcher.new + run_context = Chef::RunContext.new(node, {}, events) + described_class.new(new_resource, run_context) + end + + it "responds to load_current_resource" do + expect(provider).to respond_to(:load_current_resource) + end +end |