Skip to content

Instantly share code, notes, and snippets.

@pudquick
Last active July 14, 2020 13:33
Show Gist options
  • Select an option

  • Save pudquick/6c38ed97a8178ec91c4049b0e20dd69c to your computer and use it in GitHub Desktop.

Select an option

Save pudquick/6c38ed97a8178ec91c4049b0e20dd69c to your computer and use it in GitHub Desktop.

Revisions

  1. pudquick revised this gist Dec 12, 2018. 1 changed file with 32 additions and 54 deletions.
    86 changes: 32 additions & 54 deletions chef_user_resource_monkeypatching.rb
    Original file line number Diff line number Diff line change
    @@ -1,19 +1,15 @@
    require 'base64'
    require 'plist'

    module Chef::Provider::User::DsclUserMojaveExtensions
    module Chef::Provider::User::DsclMojaveUserExtensions
    # new for 10.14+
    def mac_osx_version_greater_than_10_13?
    # eventually we'll use this for all versions, so unlock for CPE & trusted
    return true if node.gk?(['cpe_trusted_testers', 'cpe'])
    Gem::Version.new(node['platform_version']) > Gem::Version.new('10.13.99')
    end

    # updated for 10.14+
    # this fixes this bug: https://github.com/chef/chef/issues/5777
    def load_current_resource
    super
    current_resource.home('') if current_resource.home.nil?
    # fixes bug where chef compared hash to plaintext password
    # only applies to salted_sha512_pbkdf2, which is in 10.8+
    if mac_osx_version_greater_than_10_7?
    @@ -57,7 +53,7 @@ def load_current_resource
    # new for 10.14+
    # runner for dsimport now that we can't write to user plists directly
    def run_dsimport(*args)
    result = shell_out_compact('/usr/bin/dsimport', args)
    result = shell_out('/usr/bin/dsimport', *(args.compact))
    raise(Chef::Exceptions::DsimportCommandFailed,
    "dsimport error: #{result.inspect}") unless result.exitstatus == 0
    result.stdout
    @@ -66,25 +62,13 @@ def run_dsimport(*args)
    # new for 10.14+
    # runner for dscl in plist mode
    def run_dscl_plist(*args)
    result = shell_out_compact('/usr/bin/dscl', '-plist', '.',
    "-#{args[0]}", args[1..-1])
    result = shell_out('/usr/bin/dscl', '-plist', '.',
    "-#{args[0]}", *((args[1..-1]).compact))
    return '' if ( result.exitstatus != 0 )
    # Unlike run_dscl, we don't want to raise an error here
    result.stdout
    end

    # new for 10.14+
    # runner for dseditgroup manipulations
    def run_dseditgroup(*args)
    # Ensure that our information is accurate
    shell_out_compact('/usr/bin/dscacheutil', '-flushcache')
    result = shell_out_compact('/usr/sbin/dseditgroup', '-o', 'edit', '-n',
    '/Local/Default', '-t', 'user', args)
    raise(Chef::Exceptions::DseditgroupCommandFailed,
    "dseditgroup error: #{result.inspect}") unless result.exitstatus == 0
    result.stdout
    end

    # new for 10.14+
    # the output of dscl -plist isn't identical to reading the user
    # .plist XML file directly, this repairs the portions we care about
    @@ -108,30 +92,31 @@ def reformat_user_info(user_hash)
    end

    # patched for 10.14+
    def remove_user
    def create_user
    # if we're not on 10.14+, return prior behavior
    unless mac_osx_version_greater_than_10_13?
    return super
    end
    dscl_create_user
    # set_password modifies the plist file of the user directly. So update
    # the password first before making any modifications to the user.
    set_password
    dscl_create_comment
    # dscl_set_uid - it is illegal to change the uid after the user is created
    dscl_set_gid
    dscl_set_home
    dscl_set_shell
    end

    # We can't safely do this in 10.14, due to TCC

    # if new_resource.manage_home
    # # Remove home directory
    # FileUtils.rm_rf(current_resource.home)
    # end

    # Remove the user from its groups
    run_dscl('list', '/Groups').each_line do |group|
    if member_of_group?(group.chomp)
    # This ensures removal from both GroupMembership and
    # GroupMembers without needing to know the GeneratedUID
    run_dseditgroup('-d', new_resource.username, group.chomp)
    end
    # patched for 10.14+
    def dscl_create_user
    # if we're not on 10.14+, return prior behavior
    unless mac_osx_version_greater_than_10_13?
    return super
    end

    # Remove user account
    run_dscl('delete', "/Users/#{new_resource.username}")
    # We now need to figure out and specify the uid at creation time
    new_resource.uid(get_free_uid) if new_resource.uid.nil? || new_resource.uid == ""
    run_dscl("create", "/Users/#{new_resource.username}", "UniqueID", new_resource.uid)
    end

    # patched for 10.14+
    @@ -143,7 +128,7 @@ def read_user_info

    # We flush the cache here in order to make sure that we read
    # fresh information for the user.
    shell_out_compact('/usr/bin/dscacheutil', '-flushcache')
    shell_out('/usr/bin/dscacheutil', '-flushcache')

    user_info = nil
    begin
    @@ -169,7 +154,7 @@ def set_password

    # Shadow is saved as binary plist. Convert the info to binary plist.
    shadow_info_binary = StringIO.new
    shell_out_compact('/usr/bin/plutil', '-convert', 'binary1', '-o', '-', '-',
    shell_out('/usr/bin/plutil', '-convert', 'binary1', '-o', '-', '-',
    input: shadow_info.to_plist,
    live_stream: shadow_info_binary)

    @@ -235,21 +220,19 @@ def set_password
    end
    end

    module Chef::Provider::Group::DsclGroupMojaveExtensions
    module Chef::Provider::Group::DsclMojaveGroupExtensions
    # new for 10.14+
    def mac_osx_version_greater_than_10_13?
    # eventually we'll use this for all versions, so unlock for CPE & trusted
    return true if node.gk?(['cpe_trusted_testers', 'cpe'])
    Gem::Version.new(node['platform_version']) > Gem::Version.new('10.13.99')
    end

    # new for 10.14+
    # runner for dseditgroup manipulations
    def run_dseditgroup(*args)
    # Ensure that our information is accurate
    shell_out_compact('/usr/bin/dscacheutil', '-flushcache')
    result = shell_out_compact('/usr/sbin/dseditgroup', '-o', 'edit', '-n',
    '/Local/Default', '-t', 'user', args)
    shell_out('/usr/bin/dscacheutil', '-flushcache')
    result = shell_out('/usr/sbin/dseditgroup', '-o', 'edit', '-n',
    '/Local/Default', '-t', 'user', *(args.compact))
    raise(Chef::Exceptions::DseditgroupCommandFailed,
    "dseditgroup error: #{result.inspect}") unless result.exitstatus == 0
    result.stdout
    @@ -298,20 +281,15 @@ def set_members
    end
    end
    end

    end

    class Chef
    class Provider
    class User
    class Dscl
    prepend Chef::Provider::User::DsclUserMojaveExtensions
    end
    end
    class Group
    class Dscl
    prepend Chef::Provider::Group::DsclGroupMojaveExtensions
    prepend Chef::Provider::User::DsclMojaveUserExtensions
    prepend Chef::Provider::Group::DsclMojaveGroupExtensions
    end
    end
    end
    end
    end
  2. pudquick created this gist Oct 19, 2018.
    317 changes: 317 additions & 0 deletions chef_user_resource_monkeypatching.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,317 @@
    require 'base64'
    require 'plist'

    module Chef::Provider::User::DsclUserMojaveExtensions
    # new for 10.14+
    def mac_osx_version_greater_than_10_13?
    # eventually we'll use this for all versions, so unlock for CPE & trusted
    return true if node.gk?(['cpe_trusted_testers', 'cpe'])
    Gem::Version.new(node['platform_version']) > Gem::Version.new('10.13.99')
    end

    # updated for 10.14+
    # this fixes this bug: https://github.com/chef/chef/issues/5777
    def load_current_resource
    super
    current_resource.home('') if current_resource.home.nil?
    # fixes bug where chef compared hash to plaintext password
    # only applies to salted_sha512_pbkdf2, which is in 10.8+
    if mac_osx_version_greater_than_10_7?
    if !new_resource.password.nil? && !current_resource.password.nil?
    # only run if we have passwords to compare
    if !salted_sha512_pbkdf2?(new_resource.password)
    # if we're not using a hex hash but instead a real password
    if salted_sha512_pbkdf2_password_match?
    # if the hash matches the password, make the resource.password match
    current_resource.password(new_resource.password)
    end
    end
    end
    end
    current_resource
    end

    # Brought into the extension namespace
    DSCL_PROPERTY_MAP = Chef::Provider::User::Dscl::DSCL_PROPERTY_MAP

    # new for 10.14+
    # mapping for raw DS attribute names, which dscl outputs
    DSCL_RAW_PROPERTY_MAP = {
    uid: 'dsAttrTypeStandard:UniqueID',
    gid: 'dsAttrTypeStandard:PrimaryGroupID',
    home: 'dsAttrTypeStandard:NFSHomeDirectory',
    shell: 'dsAttrTypeStandard:UserShell',
    comment: 'dsAttrTypeStandard:RealName',
    password: 'dsAttrTypeStandard:Password',
    auth_authority: 'dsAttrTypeStandard:AuthenticationAuthority',
    shadow_hash: 'dsAttrTypeNative:ShadowHashData',
    }.freeze

    # new for 10.14+
    # array of data type attributes that dscl improperly outputs as strings
    # that we need to repair
    DSCL_DATA_KEYS = [
    'dsAttrTypeNative:ShadowHashData',
    ].freeze

    # new for 10.14+
    # runner for dsimport now that we can't write to user plists directly
    def run_dsimport(*args)
    result = shell_out_compact('/usr/bin/dsimport', args)
    raise(Chef::Exceptions::DsimportCommandFailed,
    "dsimport error: #{result.inspect}") unless result.exitstatus == 0
    result.stdout
    end

    # new for 10.14+
    # runner for dscl in plist mode
    def run_dscl_plist(*args)
    result = shell_out_compact('/usr/bin/dscl', '-plist', '.',
    "-#{args[0]}", args[1..-1])
    return '' if ( result.exitstatus != 0 )
    # Unlike run_dscl, we don't want to raise an error here
    result.stdout
    end

    # new for 10.14+
    # runner for dseditgroup manipulations
    def run_dseditgroup(*args)
    # Ensure that our information is accurate
    shell_out_compact('/usr/bin/dscacheutil', '-flushcache')
    result = shell_out_compact('/usr/sbin/dseditgroup', '-o', 'edit', '-n',
    '/Local/Default', '-t', 'user', args)
    raise(Chef::Exceptions::DseditgroupCommandFailed,
    "dseditgroup error: #{result.inspect}") unless result.exitstatus == 0
    result.stdout
    end

    # new for 10.14+
    # the output of dscl -plist isn't identical to reading the user
    # .plist XML file directly, this repairs the portions we care about
    def reformat_user_info(user_hash)
    return if user_hash.nil?
    user_info = {}
    user_hash.each do |k,v|
    if DSCL_DATA_KEYS.include?(k)
    # this key is usually a data key, fix the value if we detect it to be
    if v.first.match('^(\h+ ?)+$')
    v = [StringIO.new([v.first.delete(' ')].pack('H*'))]
    end
    end
    if DSCL_RAW_PROPERTY_MAP.has_value?(k)
    # remap keys to match what they were in the XML .plist
    k = DSCL_PROPERTY_MAP[DSCL_RAW_PROPERTY_MAP.key(k)]
    end
    user_info[k] = v
    end
    user_info
    end

    # patched for 10.14+
    def remove_user
    # if we're not on 10.14+, return prior behavior
    unless mac_osx_version_greater_than_10_13?
    return super
    end

    # We can't safely do this in 10.14, due to TCC

    # if new_resource.manage_home
    # # Remove home directory
    # FileUtils.rm_rf(current_resource.home)
    # end

    # Remove the user from its groups
    run_dscl('list', '/Groups').each_line do |group|
    if member_of_group?(group.chomp)
    # This ensures removal from both GroupMembership and
    # GroupMembers without needing to know the GeneratedUID
    run_dseditgroup('-d', new_resource.username, group.chomp)
    end
    end

    # Remove user account
    run_dscl('delete', "/Users/#{new_resource.username}")
    end

    # patched for 10.14+
    def read_user_info
    # if we're not on 10.14+, return prior behavior
    unless mac_osx_version_greater_than_10_13?
    return super
    end

    # We flush the cache here in order to make sure that we read
    # fresh information for the user.
    shell_out_compact('/usr/bin/dscacheutil', '-flushcache')

    user_info = nil
    begin
    user_plist = run_dscl_plist('read', "/Users/#{new_resource.username}")
    user_record = Plist.parse_xml(user_plist)
    user_info = reformat_user_info(user_record)
    rescue Chef::Exceptions::PlistUtilCommandFailed
    end
    user_info
    end

    # patched for 10.14+
    def set_password
    # if we're not on 10.14+, return prior behavior
    unless mac_osx_version_greater_than_10_13?
    return super
    end

    # Return if there is no password to set
    return if new_resource.password.nil?

    shadow_info = prepare_password_shadow_info

    # Shadow is saved as binary plist. Convert the info to binary plist.
    shadow_info_binary = StringIO.new
    shell_out_compact('/usr/bin/plutil', '-convert', 'binary1', '-o', '-', '-',
    input: shadow_info.to_plist,
    live_stream: shadow_info_binary)

    if user_info.nil?
    # User is just created. read_user_info() will read the fresh
    # information for the user with a cache flush. However with
    # experimentation we've seen that dscl cache is not immediately
    # updated after the creation of the user.
    # This is odd and needs to be investigated further.
    sleep 3
    @user_info = read_user_info
    end

    # Replace the shadow info in user's plist
    dscl_set(user_info, :shadow_hash, shadow_info_binary)
    # 10.14 removed the ability to write to user plists directly
    # instead, we need to use dsimport to merge the value into the record
    begin
    t_name = "#{Chef::Config['file_cache_path']}/shash.tmp"
    b64_shadow = ::Base64.strict_encode64(shadow_info_binary.string)
    # the dsimport record format is:
    # record definition delimiter (space in hex)
    # escape delimiter (backslash in hex)
    # record value delimiter (colon in hex)
    # record array value delimimter (comma in hex)
    # OpenDirectory record type
    # number of attributes per record
    # [delimited list of record attribute names]
    # we are defining a minimal record: record name + shadowhashdata
    t_user = 'dsRecTypeStandard:Users'
    r_name = 'dsAttrTypeStandard:RecordName'
    r_shad = 'base64:dsAttrTypeNative:ShadowHashData'
    t_dsimport = <<~HEREDOC
    0x0A 0x5C 0x3A 0x2C #{t_user} 2 #{r_name} #{r_shad}
    #{new_resource.username}:#{b64_shadow}
    HEREDOC
    # unfortunately dsimport only works with real files using mmap
    # so we ensure that the file does not exist already by using EXCL
    # to fail on open (like a lock file) to make sure we have full
    # control and ensure 0600 permissions during its usage
    exclusive_mode = ::File::WRONLY|::File::CREAT|::File::EXCL
    ::File.delete(t_name) if ::File.exist?(t_name)
    ::File.open(t_name, exclusive_mode, 0600) do |f|
    f.write t_dsimport
    end
    result = run_dscl('delete',
    "/Users/#{new_resource.username}",
    'ShadowHashData')
    result = run_dsimport(t_name, '/Local/Default', 'M')
    ::File.delete(t_name) if ::File.exist?(t_name)
    result = run_dscl('create',
    "/Users/#{new_resource.username}",
    'Password', '********')
    rescue => e
    # if there's an error, delete the temp file
    ::File.delete(t_name) if ::File.exist?(t_name)
    log_fatal(
    :exception => e,
    :message => '[User::Dscl::set_password] Exception with hash: ' +
    new_resource.username,
    )
    end
    end
    end

    module Chef::Provider::Group::DsclGroupMojaveExtensions
    # new for 10.14+
    def mac_osx_version_greater_than_10_13?
    # eventually we'll use this for all versions, so unlock for CPE & trusted
    return true if node.gk?(['cpe_trusted_testers', 'cpe'])
    Gem::Version.new(node['platform_version']) > Gem::Version.new('10.13.99')
    end

    # new for 10.14+
    # runner for dseditgroup manipulations
    def run_dseditgroup(*args)
    # Ensure that our information is accurate
    shell_out_compact('/usr/bin/dscacheutil', '-flushcache')
    result = shell_out_compact('/usr/sbin/dseditgroup', '-o', 'edit', '-n',
    '/Local/Default', '-t', 'user', args)
    raise(Chef::Exceptions::DseditgroupCommandFailed,
    "dseditgroup error: #{result.inspect}") unless result.exitstatus == 0
    result.stdout
    end

    # patched for 10.14+
    def set_members
    unless mac_osx_version_greater_than_10_13?
    return super
    end
    # First reset the memberships if the append is not set
    unless new_resource.append
    logger.trace("#{new_resource} removing group members #{current_resource.members.join(' ')}") unless current_resource.members.empty?
    safe_dscl("create", "/Groups/#{new_resource.group_name}", "GroupMembers", "") # clear guid list
    safe_dscl("create", "/Groups/#{new_resource.group_name}", "GroupMembership", "") # clear user list
    current_resource.members([ ])
    end

    # Add any members that need to be added
    if new_resource.members && !new_resource.members.empty?
    members_to_be_added = [ ]
    new_resource.members.each do |member|
    members_to_be_added << member unless current_resource.members.include?(member)
    end
    unless members_to_be_added.empty?
    logger.trace("#{new_resource} setting group members #{members_to_be_added.join(', ')}")
    # safe_dscl("append", "/Groups/#{new_resource.group_name}", "GroupMembership", *members_to_be_added)
    members_to_be_added.each do |username|
    run_dseditgroup('-a', username, new_resource.group_name)
    end
    end
    end

    # Remove any members that need to be removed
    if new_resource.excluded_members && !new_resource.excluded_members.empty?
    members_to_be_removed = [ ]
    new_resource.excluded_members.each do |member|
    members_to_be_removed << member if current_resource.members.include?(member)
    end
    unless members_to_be_removed.empty?
    logger.trace("#{new_resource} removing group members #{members_to_be_removed.join(', ')}")
    # safe_dscl("delete", "/Groups/#{new_resource.group_name}", "GroupMembership", *members_to_be_removed)
    members_to_be_removed.each do |username|
    run_dseditgroup('-d', username, new_resource.group_name)
    end
    end
    end
    end

    end

    class Chef
    class Provider
    class User
    class Dscl
    prepend Chef::Provider::User::DsclUserMojaveExtensions
    end
    end
    class Group
    class Dscl
    prepend Chef::Provider::Group::DsclGroupMojaveExtensions
    end
    end
    end
    end