#!/usr/local/ruby-current/bin/ruby # Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License in the root of the project or at # # http://egt-labs.com/mu/LICENSE.html # # 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 'net-ldap' module MU class Master # Routines for manipulating users and groups in 389 Directory Services or Active Directory. class LDAP # Exception class specifically for LDAP-related errors class MuLDAPError < MU::MuError;end require 'date' # Make sure the LDAP section of $MU_CFG makes sense. def self.validateConfig(skipvaults: false) ok = true supported = ["Active Directory", "389 Directory Services"] if !$MU_CFG raise MuLDAPError, "Configuration not loaded yet, but MU::Master::LDAP.validateConfig was called!" end if !$MU_CFG.has_key?("ldap") raise MuLDAPError "Missing 'ldap' section of config (files: #{$MU_CFG['config_files']})" end ldap = $MU_CFG["ldap"] # shorthand if !ldap.has_key?("type") or !supported.include?(ldap["type"]) ok = false MU.log "Bad or missing 'type' of LDAP server (should be one of #{supported})", MU::ERR end ["base_dn", "user_ou", "domain_name", "domain_netbios_name", "user_group_dn", "user_group_name", "admin_group_dn", "admin_group_name"].each { |var| if !ldap.has_key?(var) or !ldap[var].is_a?(String) ok = false MU.log "LDAP config section parameter '#{var}' is missing or is not a String", MU::ERR end } if !ldap.has_key?("dcs") or !ldap["dcs"].is_a?(Array) or ldap["dcs"].size < 1 ok = false MU.log "Missing or empty 'dcs' section of LDAP config" end ["bind_creds", "join_creds"].each { |creds| if !ldap.has_key?(creds) or !ldap[creds].is_a?(Hash) or !ldap[creds].has_key?("vault") or !ldap[creds].has_key?("item") or !ldap[creds].has_key?("username_field") or !ldap[creds].has_key?("password_field") MU.log "LDAP config subsection '#{creds}' misconfigured, should be hash containing: vault, item, username_field, password_field", MU::ERR ok = false next end if !skipvaults loaded = MU::Groomer::Chef.getSecret(vault: ldap[creds]["vault"], item: ldap[creds]["item"]) if !loaded or !loaded.has_key?(ldap[creds]["username_field"]) or loaded[ldap[creds]["username_field"]].empty? or !loaded.has_key?(ldap[creds]["password_field"]) or loaded[ldap[creds]["password_field"]].empty? MU.log "LDAP config subsection '#{creds}' refers to a bogus vault or incorrect/missing item fields", MU::ERR, details: ldap[creds] ok = false end end } if !ok raise MuLDAPError, "One or more LDAP configuration errors from files #{$MU_CFG['config_files']}" end end @ldap_conn = nil @gid_attr = "cn" @gidnum_attr = "gidNumber" @member_attr = "memberUid" @uid_attr = "uid" @group_class = "posixGroup" @uid_range_start = 10000 @gid_range_start = 10000 # Create and return a connection to our directory service. If we've # already opened one, return that. # @param username [String]: Optional alternative bind user, usually just used to see if someone knows their password # @param password [String]: Optional alternative bind password # @return [Net::LDAP] def self.getLDAPConnection(username: nil, password: nil) return @ldap_conn if @ldap_conn validateConfig(skipvaults: (username and password)) if $MU_CFG["ldap"]["type"] == "Active Directory" @gid_attr = "sAMAccountName" @member_attr = "member" @uid_attr = "sAMAccountName" @group_class = "group" @user_class = "user" end if (username and !password) or (password and !username) raise MuLDAPError, "When supply credentials to getLDAPConnection, both username and password must be specified" end if !username and !password bind_creds = MU::Groomer::Chef.getSecret(vault: $MU_CFG["ldap"]["bind_creds"]["vault"], item: $MU_CFG["ldap"]["bind_creds"]["item"]) username = bind_creds[$MU_CFG["ldap"]["bind_creds"]["username_field"]] password = bind_creds[$MU_CFG["ldap"]["bind_creds"]["password_field"]] end @ldap_conn = Net::LDAP.new( :host => $MU_CFG["ldap"]["dcs"].first, :encryption => { :method => :simple_tls, :tls_options => {} }, :port => 636, :base => $MU_CFG["ldap"]["base_dn"], :auth => { :method => :simple, :username => username, :password => password } ) @ldap_conn end # If there is an active LDAP connection loaded, close it. Well, nil it # out. There's no close method, that's theoretically handled in garbage # collection. def self.dropLDAPConnection @ldap_conn = nil end # Fetch a list of numeric uids that are already allocated def self.getUsedUids used_uids = [] if $MU_CFG["ldap"]["type"] == "389 Directory Services" user_filter = Net::LDAP::Filter.ne("objectclass", "computer") & Net::LDAP::Filter.ne("objectclass", "group") conn = getLDAPConnection conn.search( :filter => user_filter, :base => $MU_CFG["ldap"]["base_dn"], :attributes => ["employeeNumber"] ) do |acct| if acct[:employeenumber] and acct[:employeenumber].size > 0 used_uids << acct[:employeenumber].first.to_i end end else Etc.passwd{ |u| if !user.nil? and u.name == user and mu_acct raise MuLDAPError, "Username #{user} already exists as a system user, cannot allocate in directory" end used_uids << u.uid } end used_uids end # Find a user ID not currently in use from the local system's perspective def self.allocateUID MU::MommaCat.lock("uid_generator", false, true) used_uids = getUsedUids for x in @uid_range_start..65535 do if !used_uids.include?(x) MU::MommaCat.unlock("uid_generator", true) return x.to_s end end MU::MommaCat.unlock("uid_generator", true) return nil end # Find a group ID not currently in use from the local system's perspective # XXX this is vulnerable to a race condition, and may not account for # things in the directory def self.allocateGID(group: nil) MU::MommaCat.lock("gid_generator", false, true) used_gids = [] Etc.group{ |g| if !group.nil? and g.name == group raise MuLDAPError, "Group #{group} already exists as a local system group, cannot allocate in directory" end used_gids << g.gid } conn = getLDAPConnection conn.search( :filter => Net::LDAP::Filter.eq("objectClass", @group_class), :base => $MU_CFG['ldap']['base_dn'], :attributes => [@gidnum_attr] ) { |item| used_gids = used_gids + item[@gidnum_attr].map { |x| x.to_i } } for x in @gid_range_start..65535 do if !used_gids.include?(x) MU::MommaCat.unlock("gid_generator", true) return x.to_s end end MU::MommaCat.unlock("gid_generator", true) return nil end # Create a directory group. Valid for 389 DS only, will fail on AD. def self.createGroup(group, full_dn: nil) dn = "CN=#{group},"+$MU_CFG["ldap"]["group_ou"] dn = full_dn if !full_dn.nil? gid = allocateGID attr = { :cn => group, :description => "#{group} Group", :gidNumber => gid, :objectclass => ["top", "posixGroup"] } if !@ldap_conn.add( :dn => dn, :attributes => attr ) and @ldap_conn.get_operation_result.code != 68 MU.log "Error creating #{dn}: "+getLDAPErr, MU::ERR, details: attr return false elsif @ldap_conn.get_operation_result.code != 68 MU.log "Created group #{dn} with gid #{gid}", MU::NOTICE end return gid end # Intended to run when Mu's local LDAP server has been created. Use the # root credentials to populate our OU structure, create other users, etc. # This only needs to understand a 389 Directory style schema, since # obviously we're not running Active Directory locally on Linux. def self.initLocalLDAP validateConfig if $MU_CFG["ldap"]["type"] != "389 Directory Services" or # XXX this should check all of the IPs and hostnames we're known by (!$MU_CFG["ldap"]["dcs"].include?("localhost") and !$MU_CFG["ldap"]["dcs"].include?("127.0.0.1")) MU.log "Custom directory service configured, not initializing bundled schema", MU::NOTICE return end root_creds = MU::Groomer::Chef.getSecret(vault: "mu_ldap", item: "root_dn_user") @ldap_conn = Net::LDAP.new( :host => "127.0.0.1", :encryption => { :method => :simple_tls, :tls_options => {} }, :port => 636, :base => "", :auth => { :method => :simple, :username => root_creds["username"], :password => root_creds["password"] } ) # Manufacture our OU tree and groups [$MU_CFG["ldap"]["base_dn"], "OU=Mu-System,#{$MU_CFG["ldap"]["base_dn"]}", $MU_CFG["ldap"]["user_ou"], $MU_CFG["ldap"]["group_ou"], $MU_CFG["ldap"]["user_group_dn"], $MU_CFG["ldap"]["admin_group_dn"] ].each { |full_dn| dn = "" full_dn.split(/,/).reverse.each { |chunk| if dn.empty? dn = chunk else dn = "#{chunk},#{dn}" end next if chunk.match(/^DC=/i) if chunk.match(/^OU=(.*)/i) ou = $1 if !@ldap_conn.add( :dn => dn, :attributes => { :ou => ou, :objectclass =>"organizationalUnit" } ) and @ldap_conn.get_operation_result.code != 68 # "already exists" MU.log "Error creating #{dn}: "+getLDAPErr, MU::ERR return false elsif @ldap_conn.get_operation_result.code != 68 MU.log "Created OU #{dn}", MU::NOTICE end elsif chunk.match(/^CN=(.*)/i) createGroup($1, full_dn: dn) end } } ["bind_creds", "join_creds"].each { |creds| data = MU::Groomer::Chef.getSecret(vault: $MU_CFG["ldap"][creds]["vault"], item: $MU_CFG["ldap"][creds]["item"]) user_dn = data[$MU_CFG["ldap"][creds]["username_field"]] user_dn.match(/^CN=(.*?),/i) username = $1 pw = data[$MU_CFG["ldap"][creds]["password_field"]] attr = { :cn => username, :displayName => "Mu Service Account", :objectclass => ["top", "person", "organizationalPerson", "inetorgperson"], :uid => username, :mail => $MU_CFG['mu_admin_email'], :givenName => "Mu", :sn => "Service", :userPassword => pw } if !@ldap_conn.add( :dn => data[$MU_CFG["ldap"][creds]["username_field"]], :attributes => attr ) and @ldap_conn.get_operation_result.code != 68 raise MuLDAPError, "Failed to create user #{user_dn} (#{getLDAPErr})" elsif @ldap_conn.get_operation_result.code != 68 MU.log "Created #{username} (#{user_dn})", MU::NOTICE end # Set the password if !@ldap_conn.replace_attribute(user_dn, :userPassword, [pw]) MU.log "Couldn't update password for user #{username}.", MU::ERR, details: getLDAPErr end # Grant this user appropriate privileges targets = [] if creds == "bind_creds" targets << $MU_CFG["ldap"]["user_ou"] targets << $MU_CFG["ldap"]["group_ou"] targets << $MU_CFG["ldap"]["user_group_dn"] targets << $MU_CFG["ldap"]["admin_group_dn"] elsif creds == "join_creds" # XXX Some machine-related OU? end targets.each { | target| aci = "(targetattr=\"*\")(target=\"ldap:///#{target}\")(version 3.0; acl \"#{username} admin privileges for #{target}\"; allow (all) userdn=\"ldap:///#{user_dn}\";)" if !@ldap_conn.modify(:dn => $MU_CFG["ldap"]["base_dn"], :operations => [[:add, :aci, aci]]) and @ldap_conn.get_operation_result.code != 20 MU.log "Couldn't modify permissions for user #{username}.", MU::ERR, details: getLDAPErr elsif @ldap_conn.get_operation_result.code != 20 MU.log "Granted #{username} user admin privileges over #{target}", MU::NOTICE end } } end # Shorthand for fetching the most recent error on the active LDAP # connection def self.getLDAPErr return nil if !@ldap_conn return @ldap_conn.get_operation_result.code.to_s+" "+@ldap_conn.get_operation_result.message.to_s end # Approximate a current Microsoft timestamp. They count the number of # 100-nanoseconds intervals (1 nanosecond = one billionth of a second) # since Jan 1, 1601 UTC. def self.getMicrosoftTime ms_epoch = DateTime.new(1601,1,1) # this is in milliseconds, so multiply it for the right number of zeroes elapsed = DateTime.now.strftime("%Q").to_i - ms_epoch.strftime("%Q").to_i return elapsed*10000 end # Convert a Microsoft timestamp to a Ruby Time object. See also #getMicrosoftTime. # @param stamp [Integer]: The MS-style timestamp, e.g. 130838184558490696 # @return [Time] def self.convertMicrosoftTime(stamp) ms_epoch = DateTime.new(1601,1,1).strftime("%Q").to_i unixtime = (stamp.to_i/10000) + DateTime.new(1601,1,1).strftime("%Q").to_i Time.at(unixtime/1000) end @can_write = nil # Test whether our LDAP binding user has permissions to create other # users, manipulate groups, and set passwords. Note that it's *not* fatal # if we can't, simply a design where most account management happens on # the directory side. # @return [Boolean] def self.canWriteLDAP? return @can_write if !@can_write.nil? conn = getLDAPConnection dn = "CN=Mu Testuser #{Process.pid},#{$MU_CFG["ldap"]["user_ou"]}" uid = "mu.testuser.#{Process.pid}" attr = { :cn => "Mu Testuser #{Process.pid}", @uid_attr.to_sym => uid } if $MU_CFG["ldap"]["type"] == "Active Directory" attr[:objectclass] = ["user"] attr[:userPrincipalName] = "#{uid}@#{$MU_CFG["ldap"]["domain_name"]}" attr[:pwdLastSet] = "-1" uid = dn elsif $MU_CFG["ldap"]["type"] == "389 Directory Services" attr[:objectclass] = ["top", "person", "organizationalPerson", "inetorgperson"] attr[:userPassword] = Password.pronounceable(12..14) attr[:displayName] = "Mu Test User #{Process.pid}" attr[:mail] = $MU_CFG['mu_admin_email'] attr[:givenName] = "Mu" attr[:sn] = "TestUser" end @can_write = true if !conn.add(:dn => dn, :attributes => attr) MU.log "Couldn't create write-test user #{dn}, operating in read-only LDAP mode (#{getLDAPErr})", MU::NOTICE, details: attr return false end # Make sure we can write various fields that we might need to touch [:displayName, :mail, :givenName, :sn].each { |field| if !conn.replace_attribute(dn, field, "foo@bar.com") MU.log "Couldn't modify write-test user #{dn} field #{field.to_s}, operating in read-only LDAP mode (#{getLDAPErr})", MU::NOTICE @can_write = false end } # Can we add them to the Mu membership group(s) [$MU_CFG["ldap"]["user_group_dn"], $MU_CFG["ldap"]["admin_group_dn"]].each { |group| if !conn.modify(:dn => group, :operations => [[:add, @member_attr, uid]]) MU.log "Couldn't add write-test user #{dn} to #{@member_attr} in group #{group}, operating in read-only LDAP mode (#{getLDAPErr})", MU::NOTICE @can_write = false end } if !conn.delete(:dn => dn) MU.log "Couldn't delete write-test user #{dn}, operating in read-only LDAP mode", MU::NOTICE @can_write = false end @can_write end # Search for groups whose names contain any of the given search terms and # return their full DNs. # @param search [Array]: Strings to search for. # @param exact [Boolean]: Return only exact matches for whole fields. # @param searchbase [String]: The DN under which to search. # @return [Array] def self.findGroups(search = [], exact: false, searchbase: $MU_CFG['ldap']['base_dn']) if search.nil? or search.size == 0 raise MuLDAPError, "Need something to search for in MU::Master::LDAP.findGroups" end conn = getLDAPConnection filter = nil search.each { |term| curfilter = Net::LDAP::Filter.contains(@gid_attr, "#{term}") if exact curfilter = Net::LDAP::Filter.eq(@gid_attr, "#{term}") end if !filter filter = curfilter else filter = filter | curfilter end } filter = Net::LDAP::Filter.ne("objectclass", "computer") & (filter) groups = [] conn.search( :filter => filter, :base => searchbase, :attributes => ["objectclass"] ) do |group| groups << group.dn end groups end # See https://technet.microsoft.com/en-us/library/ee198831.aspx AD_PW_ATTRS = { 'script' => 0x0001, #SCRIPT # 'disable' => 0x0002, #ACCOUNTDISABLE 'disable' => 0b0000010, #ACCOUNTDISABLE 'homedirRequired' => 0x0008, #HOMEDIR_REQUIRED 'lockout' => 0x0010, #LOCKOUT 'noPwdRequired' => 0x0020, #ADS_UF_PASSWD_NOTREQD 'cantChangePwd' => 0x0040, #ADS_UF_PASSWD_CANT_CHANGE 'pwdStoredReversible' => 0x0080, #ADS_UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED 'tempDuplicateAccount' => 0x0100, #NORMAL_ACCOUNT 'normal' => 0x0200, #NORMAL_ACCOUNT 'pwdNeverExpires' => 0x10000, #ADS_UF_DONT_EXPIRE_PASSWD 'pwdExpired' => 0x80000, #ADS_UF_PASSWORD_EXPIRED 'trustedToAuthForDelegation' => 0x1000000 #TRUSTED_TO_AUTH_FOR_DELEGATION }.freeze # Find a directory user with fuzzy string matching on sAMAccountName/uid, displayName, group memberships, or email # @param search [Array]: Strings to search for. # @param exact [Boolean]: Return only exact matches for whole fields. # @param searchbase [String]: The DN under which to search. # @param extra_attrs [Array]: Other LDAP attributes to search # @param matchgroups [Array]: An array of groups. If supplied, a user must be a member of one of these in order to match. # @return [Array] def self.findUsers(search = [], exact: false, searchbase: $MU_CFG['ldap']['base_dn'], extra_attrs: [], matchgroups: []) # We want to search groups, but can't search on memberOf with wildcards. # So search groups independently, build a list of full CNs, and use # those. if search.size > 0 groups = findGroups(search, exact: exact, searchbase: searchbase) end searchattrs = [@uid_attr] getattrs = [] if $MU_CFG["ldap"]["type"] == "389 Directory Services" getattrs = ["uid", "displayName", "mail"] + extra_attrs elsif $MU_CFG["ldap"]["type"] == "Active Directory" getattrs = ["sAMAccountName", "displayName", "mail", "lastLogon", "lockoutTime", "pwdLastSet", "memberOf", "userAccountControl"] + extra_attrs end if !exact searchattrs = searchattrs + ["displayName", "mail"] + extra_attrs end conn = getLDAPConnection users = {} filter = nil rejected = 0 if search.size > 0 search.each { |term| if term.nil? or (term.length < 4 and !exact) MU.log "Search term '#{term}' is too short, ignoring.", MU::WARN rejected = rejected + 1 next end searchattrs.each { |attr| if !filter if exact filter = Net::LDAP::Filter.eq(attr, "#{term}") else filter = Net::LDAP::Filter.contains(attr, "#{term}") end else if exact filter = filter |Net::LDAP::Filter.eq(attr, "#{term}") else filter = filter |Net::LDAP::Filter.contains(attr, "#{term}") end end } } if rejected == search.size MU.log "No valid search strings provided.", MU::ERR return nil end end if groups groups.each { |group| filter = filter |Net::LDAP::Filter.eq("memberOf", group) } end if filter filter = Net::LDAP::Filter.ne("objectclass", "computer") & Net::LDAP::Filter.ne("objectclass", "group") & (filter) else filter = Net::LDAP::Filter.ne("objectclass", "computer") & Net::LDAP::Filter.ne("objectclass", "group") end conn.search( :filter => filter, :base => searchbase, :attributes => getattrs ) do |acct| begin next if users.has_key?(acct[@uid_attr].first) rescue NoMethodError next end if matchgroups and matchgroups.size > 0 next if (acct[:memberOf] & matchgroups).size < 1 end users[acct[@uid_attr].first] = {} users[acct[@uid_attr].first]['dn'] = acct.dn getattrs.each { |attr| begin if acct[attr].size == 1 users[acct[@uid_attr].first][attr] = acct[attr].first else users[acct[@uid_attr].first][attr] = acct[attr] end if attr == "userAccountControl" AD_PW_ATTRS.each_pair { |pw_attr, bitmask| if (bitmask | acct[attr].first.to_i) == acct[attr].first.to_i users[acct[@uid_attr].first][pw_attr] = true end } users[acct[@uid_attr].first][attr] = acct[attr].first.to_i.to_s(2) end end rescue NoMethodError } end # Make all of the Net::BER::BerIdentifiedString leaves in a Hash into # normal strings. # @param tree def self.hashStringify(tree) newtree = nil if tree.is_a?(Hash) newtree = {} tree.each_pair { |key, leaf| newtree[key.to_s] = hashStringify(leaf) } elsif tree.is_a?(Array) newtree = [] tree.each { |leaf| newtree << hashStringify(leaf) } elsif tree.is_a?(Net::BER::BerIdentifiedString) newtree = tree.to_s else newtree = tree end newtree end scrubbed_users = hashStringify(users) scrubbed_users end # Authenticate a user against our directory, optionally requiring them # to be a member of a particular group in order to return true. # @param username [String]: The bare username of the user to authorize # @param password [String]: The user's password # @return [Boolean] def self.authorize(username, password, require_group: nil) auth = nil begin # see if this user/pw combo works conn = getLDAPConnection(username: username, password: password) auth = conn.auth(username, password) if username and password rescue Net::LDAP::LdapError return false end if !conn.bind(auth) MU.log conn.get_operation_result.message, MU::ERR return false end return true if !require_group shortuser = username.sub(/\@.*/, "") user = findUsers(search = [shortuser], exact: true) if user[shortuser]["memberOf"].is_a?(Array) user[shortuser]["memberOf"].each { |group| shortname = group.sub(/^CN=(.*?),.*/, '\1') return true if shortname == require_group } elsif user[shortuser]["memberOf"].is_a?(String) shortname = user[shortuser]["memberOf"].sub(/^CN=(.*?),.*/, '\1') return true if shortname == require_group end return false end # @return [Array] def self.listUsers conn = getLDAPConnection users = {} # XXX why doesn't this work? # group_membership_filter = Net::LDAP::Filter.eq("memberOf", $MU_CFG["ldap"]["admin_group_name"]) | Net::LDAP::Filter.eq("memberOf", $MU_CFG["ldap"]["user_group_name"]) ["admin_group_name", "user_group_name"].each { |group| groupname_filter = Net::LDAP::Filter.eq(@gid_attr, $MU_CFG["ldap"][group]) group_filter = Net::LDAP::Filter.eq("objectClass", @group_class) member_uids = [] conn.search( :filter => Net::LDAP::Filter.join(groupname_filter, group_filter), :attributes => [@member_attr] ) do |item| member_uids = item[@member_attr].map { |u| u.to_s } end member_uids.each { |uid| username_filter = Net::LDAP::Filter.eq(@uid_attr, uid) if $MU_CFG["ldap"]["type"] == "Active Directory" # XXX this is a workaround, as we can't seem to look up the full # DN now for some reason. cn = uid.sub(/^CN=([^,]+?),.*/, "\\1") username_filter = Net::LDAP::Filter.eq("cn", cn) end user_filter = Net::LDAP::Filter.ne("objectclass", "computer") & Net::LDAP::Filter.ne("objectclass", "group") fetchattrs = ["cn", @uid_attr, "displayName", "mail"] fetchattrs << "employeeNumber" if $MU_CFG["ldap"]["type"] == "389 Directory Services" conn.search( :filter => username_filter & user_filter, :base => $MU_CFG["ldap"]["base_dn"], :attributes => fetchattrs ) do |acct| next if users.has_key?(acct[@uid_attr].first) users[acct[@uid_attr].first] = {} users[acct[@uid_attr].first]['dn'] = acct.dn if group == "admin_group_name" users[acct[@uid_attr].first]['admin'] = true else users[acct[@uid_attr].first]['admin'] = false end begin users[acct[@uid_attr].first]['realname'] = acct.displayname.first end rescue NoMethodError begin users[acct[@uid_attr].first]['email'] = acct.mail.first end rescue NoMethodError begin users[acct[@uid_attr].first]['uid'] = acct.employeenumber.first end rescue NoMethodError end } } users end # Delete a user from our directory # @param user [String]: The username to remove. # @return [Boolean]: Success/Failure def self.deleteUser(user) if canWriteLDAP? conn = getLDAPConnection dn = nil conn.search( :filter => Net::LDAP::Filter.eq(@uid_attr, user), :base => $MU_CFG["ldap"]["base_dn"], :attributes => [@uid_attr] ) do |acct| dn = acct.dn break end # Our default LDAP server doesn't cascade user deletes through groups, # so help it out. if $MU_CFG["ldap"]["type"] == "389 Directory Services" conn.search( :filter => Net::LDAP::Filter.eq("objectclass", @group_class), :base => $MU_CFG["ldap"]["base_dn"], :attributes => ["cn", @member_attr] ) do |group| group[@member_attr].each { |member| next if member.nil? if member.downcase == user or (!dn.nil? and member.downcase == dn.downcase) manageGroup(group.cn.first, remove_users: [user]) end } if group.cn.first.downcase == "#{user}.mu-user" and !conn.delete(:dn => group.dn) MU.log "Couldn't delete user's default group #{group.dn}", MU::WARN, details: getLDAPErr else MU.log "Removed user's default group #{user}.mu-user", MU::NOTICE end end end if !dn.nil? and !conn.delete(:dn => dn) MU.log "Failed to delete #{user} from LDAP: #{getLDAPErr}", MU::WARN, details: dn return false end MU.log "Removed LDAP user #{user}", MU::NOTICE return true else MU.log "We are in read-only LDAP mode. You must manually delete #{user} from your directory.", MU::WARN end false end # Add/remove users to/from a group. # @param group [String]: The short name of the group # @param add_users [Array]: The short names of users to add to the group # @param remove_users [Array]: The short names of users to remove from the group def self.manageGroup(group, add_users: [], remove_users: []) group_dn = findGroups([group], exact: true).first if !group_dn or group_dn.empty? raise MuLDAPError, "Failed to find a Distinguished Name for group #{group}" end if (add_users & remove_users).size > 0 raise MuError, "Can't both add and remove the same user (#{(add_users & remove_users).join(", ")}) from a group" end add_users = findUsers(add_users, exact: true) if add_users.size > 0 remove_users = findUsers(remove_users, exact: true) if remove_users.size > 0 conn = getLDAPConnection if add_users.size > 0 add_users.each_pair { |user, data| uid = user uid = data["dn"] if $MU_CFG["ldap"]["type"] == "Active Directory" if !conn.modify(:dn => group_dn, :operations => [[:add, @member_attr, uid]]) and @ldap_conn.get_operation_result.code != 20 MU.log "Couldn't add user #{user} (#{data['dn']}) to #{@member_attr} of group #{group} (#{group_dn}).", MU::WARN, details: getLDAPErr else MU.log "Added #{user} to group #{group}", MU::NOTICE end } end if remove_users.size > 0 remove_users.each_pair { |user, data| uid = user uid = data["dn"] if $MU_CFG["ldap"]["type"] == "Active Directory" if !conn.modify(:dn => group_dn, :operations => [[:delete, @member_attr, uid]]) MU.log "Couldn't remove user #{user} from group #{group} (#{group_dn}) via #{@member_attr}.", MU::WARN, details: getLDAPErr else MU.log "Removed #{user} from group #{group}", MU::NOTICE end } end end # Call when creating or modifying a user. # @param user [String]: The username on which to operate # @param password [String]: Set the user's password # @param name [String]: Full name of the user # @param email [String]: Set the user's email address # @param admin [Boolean]: Whether to flag this user as an admin # @param unlock [Boolean]: Unlock a locked account (Active Directory) # @param mu_acct [Boolean]: Whether to operate on users outside of Mu (generic directory users) # @param ou [String]: The OU into which to deposit new users. # @param disable [Boolean]: Disabled the user's account # @param enable [Boolean]: Re-enable the user's account if it's disabled def self.manageUser(user, name: nil, password: nil, email: nil, admin: false, mu_acct: true, unlock: false, ou: $MU_CFG["ldap"]["user_ou"], enable: false, disable: false, change_uid: -1) cur_users = listUsers first = last = nil if !name.nil? last = name.split(/\s+/).pop first = name.split(/\s+/).shift end conn = getLDAPConnection # If we're operating on users that aren't specifically Mu users, # fetch generic directory information about them instead of the Mu # user descriptor. if !mu_acct cur_users = findUsers([user], exact: true) end # Oh, Microsoft. Slap quotes around it, convert it to Unicode, and call # it Sally. *Then* it's a password. password_attr = :userPassword if !password.nil? and $MU_CFG["ldap"]["type"] == "Active Directory" password = ('"'+password+'"').encode("utf-16le").force_encoding("utf-8") password_attr = :unicodePwd end ok = true if !cur_users.has_key?(user) # Creating a new user if canWriteLDAP? if password.nil? or email.nil? or name.nil? raise MuLDAPError, "Missing one or more required fields (name, password, email) creating new user #{user}" end user_dn = "CN=#{name},#{ou}" conn = getLDAPConnection attr = { :cn => name, :displayName => name, :givenName => first, :sn => last, :mail => email } attr[password_attr] = password gid = nil groups = [] if $MU_CFG["ldap"]["type"] == "389 Directory Services" attr[:objectclass] = ["top", "person", "organizationalPerson", "inetorgperson"] attr[:uid] = user if change_uid > 0 used_uids = getUsedUids if used_uids.include?(change_uid) raise MuLDAPError, "Uid #{change_uid} is unavailable, cannot allocate to user #{user}" end MU.log "Forcing uid #{change_uid} to user #{user}", MU::NOTICE, details: used_uids attr[:employeeNumber] = change_uid.to_s else attr[:employeeNumber] = allocateUID end if mu_acct gid = createGroup("#{user}.mu-user") groups << "#{user}.mu-user" else gid = createGroup(user) groups << user end attr[:departmentNumber] = gid elsif $MU_CFG["ldap"]["type"] == "Active Directory" attr[:objectclass] = ["user"] attr[:samaccountname] = user attr[:userAccountControl] = AD_PW_ATTRS['normal'].to_s attr[:userPrincipalName] = "#{user}@#{$MU_CFG["ldap"]["domain_name"]}" attr[:pwdLastSet] = "-1" attr.delete(:userPassword) if mu_acct attr[:userAccountControl] = (attr[:userAccountControl].to_i & AD_PW_ATTRS['pwdNeverExpires']).to_s end if disable attr[:userAccountControl] = (attr[:userAccountControl].to_i & AD_PW_ATTRS['disable']).to_s end end if !conn.add(:dn => user_dn, :attributes => attr) if getLDAPErr.match(/53 Unwilling to perform/) raise MuLDAPError, "Failed to create user #{user} (#{getLDAPErr}). Most likely the LDAP password policy objected to the password '#{password}'" else raise MuLDAPError, "Failed to create user #{user} (#{getLDAPErr}) from add(:dn => #{user_dn}, :attributes => #{attr.to_s})" end end attr[password_attr] = "********" MU.log "Created new LDAP user #{user}", details: attr if mu_acct groups << $MU_CFG["ldap"]["user_group_name"] groups << $MU_CFG["ldap"]["admin_group_name"] if admin end groups.each { |group| manageGroup(group, add_users: [user]) } wait = 10 begin %x{/usr/bin/getent passwd ; /usr/bin/getent group} # winbind is slow sometimes Etc.getpwnam(user) rescue ArgumentError if wait >= 30 MU.log "User #{user} has been created in LDAP, but local system can't see it. Are PAM/LDAP configured correctly?", MU::ERR return false end MU.log "User #{user} has been created in LDAP, but not yet visible to local system, waiting #{wait}s and checking again.", MU::WARN sleep wait wait = wait + 5 retry end if user != "mu" %x{/sbin/restorecon -r /home} # SELinux stupidity that oddjob misses MU::Master.setLocalDataPerms(user) if Etc.getpwuid(Process.uid).name == "root" and mu_acct else MU.log "We are in read-only LDAP mode. You must first create #{user} in your directory and add it to #{$MU_CFG["ldap"]["user_group_dn"]}. If the user is intended to be an admin, also add it to #{$MU_CFG["ldap"]["admin_group_dn"]}.", MU::WARN return true end else gid = MU::Master.setLocalDataPerms(user) if Etc.getpwuid(Process.uid).name == "root" and mu_acct # Modifying an existing user if canWriteLDAP? conn = getLDAPConnection user_dn = cur_users[user]['dn'] if $MU_CFG["ldap"]["type"] == "389 Directory Services" # Make sure we have a sensible default gid conn.replace_attribute(user_dn, :departmentNumber, gid.to_s) if change_uid > 0 used_uids = getUsedUids if used_uids.include?(change_uid) raise MuLDAPError, "Uid #{change_uid} is unavailable, cannot allocate to user #{user}" end MU.log "Forcing uid #{change_uid} to user #{user}", MU::NOTICE, details: used_uids conn.replace_attribute(user_dn, :employeeNumber, change_uid.to_s) end end if !name.nil? and cur_users[user]['realname'] != name MU.log "Updating display name for #{user} to #{name}", MU::NOTICE conn.replace_attribute(user_dn, :displayName, name) conn.replace_attribute(user_dn, :givenName, first) conn.replace_attribute(user_dn, :sn, last) cur_users[user]['realname'] = name end if disable user_props = findUsers([user], exact: true) MU.log "Disabling #{user}", MU::WARN conn.replace_attribute(user_dn, :userAccountControl, AD_PW_ATTRS['disable'].to_i.to_s(2)) elsif enable user_props = findUsers([user], exact: true) MU.log "Re-enabling #{user}", MU::NOTICE uac = (("0b"+user_props[user]["userAccountControl"]).to_i & AD_PW_ATTRS['disable']) conn.replace_attribute(user_dn, :userAccountControl, uac.to_s(2)) end if unlock conn.replace_attribute(user_dn, :lockoutTime, "0") end if !email.nil? and cur_users[user]['email'] != email MU.log "Updating email for #{user} to #{email}", MU::NOTICE conn.replace_attribute(user_dn, :mail, email) cur_users[user]['email'] = email end if !password.nil? MU.log "Updating password for #{user}", MU::NOTICE if !conn.replace_attribute(user_dn, password_attr, [password]) MU.log "Couldn't update password for user #{user}.", MU::WARN, details: getLDAPErr ok = false end end if admin and !cur_users[user]['admin'] MU.log "Granting Mu admin privileges to #{user}", MU::NOTICE manageGroup($MU_CFG["ldap"]["admin_group_name"], add_users: [user]) elsif !admin and cur_users[user]['admin'] MU.log "Revoking Mu admin privileges from #{user}", MU::NOTICE manageGroup($MU_CFG["ldap"]["admin_group_name"], remove_users: [user]) end else MU.log "We are in read-only LDAP mode. You must manage #{user} in your directory.", MU::WARN ok = false end end return ok if !mu_acct # everything below is Mu-specific cur_users = listUsers if cur_users.has_key?(user) ["realname", "email", "monitoring_email"].each { |field| next if !cur_users[user].has_key?(field) File.open($MU_CFG['datadir']+"/users/#{user}/#{field}", File::CREAT|File::RDWR, 0640) { |f| f.puts cur_users[user][field] } } else MU.log "Load of current user list didn't include #{user}, even though we just created them!", MU::WARN end MU::Master.setLocalDataPerms(user) if Etc.getpwuid(Process.uid).name == "root" and mu_acct ok end end end end