modules/mu/master/ldap.rb
# 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<String>]: 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<String>]
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<String>]: 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<String>]: Other LDAP attributes to search
# @param matchgroups [Array<String>]: An array of groups. If supplied, a user must be a member of one of these in order to match.
# @return [Array<Hash>]
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([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<String>]
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<String>]: The short names of users to add to the group
# @param remove_users [Array<String>]: 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
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