Skip to content

Commit

Permalink
[ldap] Allow user customization of field mapping
Browse files Browse the repository at this point in the history
Attributes from a user's LDAP record are used during account-linking to
populate the erchef user record when it is created. Previously, the
mapping between LDAP attributes and chef user attributes were fixed.
Now, they are configurable.

For example, if the user's LDAP record stores their email address in a
field named 'address' instead of 'mail', then you could set the
following in private-chef.rb:

    ldap['email_attribute'] = "address"

Fixes #151
Fixes #800
Fixes #104
Partially addresses #675

Issue #800 was also addressed in #863 which allowed common_name to
service as a fallback for display name. The fallback is still in place
but now any field can be used for the display_name.

Issue #675 is an issue which our unicode handling. The unicode handling
is still broken; however, this would allow users to use a different
field that might not contain multi-byte characters.

Signed-off-by: Steven Danna <[email protected]>
  • Loading branch information
stevendanna committed Nov 5, 2016
1 parent af590a3 commit 925cbaa
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,32 @@
# default['private_chef']['ldap']['enable_ssl'] = false
# default['private_chef']['ldap']['enable_tls'] = false

##
# LDAP Attribute Mapping
##
# Attributes from a user's LDAP record are used during account-linking
# to populate the erchef user record when it is created. The
# following attributes controls which LDAP record is used for the
# specified information.
#
# For example, if the user's LDAP record stores their email address in
# a field named 'address' instead of 'mail', then you could set:
#
# default['private_chef']['ldap']['email_attribute'] = "address"
#
# in private-chef.rb this would look like:
#
# ldap['email_attribute'] = "address"
#
##
# default['private_chef']['ldap']['display_name_attribute'] = "displayname"
# default['private_chef']['ldap']['first_name_attribute'] = "givenname"
# default['private_chef']['ldap']['last_name_attribute'] = "sn"
# default['private_chef']['ldap']['common_name_attribute'] = "cn"
# default['private_chef']['ldap']['country_attribute'] = "c"
# default['private_chef']['ldap']['city_attribute'] = "l"
# default['private_chef']['ldap']['email_attribute'] = "mail"

##
# Upgrades/Partybus
##
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@
{base_dn, "<%= node['private_chef']['ldap']['base_dn'] || "" %>" },
{group_dn, "<%= node['private_chef']['ldap']['group_dn'] || "" %>" },
{login_attribute, "<%= node['private_chef']['ldap']['login_attribute'] || "samaccountname" %>" },
%% LDAP Attribute Mappings
{display_name_attribute, "<%= node['private_chef']['ldap']['display_name_attribute'] || 'displayname' %>" },
{first_name_attribute, "<%= node['private_chef']['ldap']['first_name_attribute'] || 'givenname' %>" },
{last_name_attribute, "<%= node['private_chef']['ldap']['last_name_attribute'] || 'sn' %>" },
{common_name_attribute, "<%= node['private_chef']['ldap']['common_name_attribute'] || 'cn' %>" },
{country_attribute, "<%= node['private_chef']['ldap']['country_attribute'] || 'c' %>" },
{city_attribute, "<%= node['private_chef']['ldap']['city_attribute'] || 'l' %>" },
{email_attribute, "<%= node['private_chef']['ldap']['email_attribute'] || 'mail' %>" },

{case_sensitive_login_attribute, <%= node['private_chef']['ldap']['case_sensitive_login_attribute'] || false %>},
{encryption, <%= @ldap_encryption_type %>}
]},
Expand Down
53 changes: 32 additions & 21 deletions src/oc_erchef/apps/oc_chef_wm/src/oc_chef_wm_authn_ldap.erl
Original file line number Diff line number Diff line change
Expand Up @@ -96,25 +96,29 @@ find_and_authenticate_user(Session, User, Password, Config) ->
% Auth so we can search for the user
ok = case {BindDN, BindPass} of
{"", ""} ->
% This is a workaround for an upstream eldap bug.
% eldap does not correctly process the anon_auth configuration,
% however, passing anon for both the BindDN and BindPass bypasses the
%
% TODO: Remove once a fix is accepted upstream and we can upgrade our
% erlang version to pull in the fix.
%% This is a workaround for an upstream eldap bug.
%% eldap does not correctly process the anon_auth
%% configuration option, however, passing anon for
%% both the BindDN and BindPass is eldap's internal
%% api for doing an anonymous bind.
%%
%% TODO: Remove once a fix is accepted upstream and
%% we can upgrade our erlang version to pull in the
%% fix.
bind(Session, anon, anon);
_ ->
bind(Session, BindDN, BindPass)
end,

% And then search
%% And then search
{ok, Result} = search_result(eldap:search(Session, [Base, Filter])),
case result_to_user_ejson(LoginAttr, User, Result) of
{error, Reason} ->
{error, Reason};
{CN, UserName, Data} ->
% We found the user identified by username, now we need to
% see if we can authorize as that user, using the provided password.
%% We found the user identified by username, now we need
%% to see if we can authorize as that user, using the
%% provided password.
case eldap:simple_bind(Session, CN, Password) of
ok -> {UserName, Data};
{error, Error} ->
Expand All @@ -126,8 +130,9 @@ find_and_authenticate_user(Session, User, Password, Config) ->
search_result({ok, {eldap_search_result, Result, _}}) ->
{ok, Result};
search_result({error, Reason}) ->
% An error response means some kind of failure occurred - no matching results
% would not result in an error tuple, but rather an empty result set.
%% An error response means some kind of failure occurred - no
%% matching results would not result in an error tuple, but rather
%% an empty result set.
lager:error("LDAP search failed unexpectedly: ~p", [Reason]),
error.

Expand Down Expand Up @@ -174,7 +179,6 @@ result_to_user_ejson(_, UserName, []) ->
lager:info("User ~p not found in LDAP", [UserName]),
{error, unauthorized};
result_to_user_ejson(LoginAttr, UserName, [{eldap_entry, CN, DataIn}|_]) ->

% No guarantees on casing, so let's not make assumptions:
Data = [ { string:to_lower(Key), Value} || {Key, Value} <- DataIn ],

Expand All @@ -197,20 +201,27 @@ result_to_user_ejson(LoginAttr, UserName, [{eldap_entry, CN, DataIn}|_]) ->
%
% Note that any missing fields in the user's directory entry (LookupFields)
% will have "unknown" substituted in the returned json record.
LookupFields = [{"displayname", <<"display_name">>},
{"givenname", <<"first_name">>},
{"sn", <<"last_name">>},
{"cn", <<"common_name">>},
{"c", <<"country">>},
{"l", <<"city">>},
{"mail", <<"email">>} ],

Terms = [ {Name, value_of(Key, Data, "unknown") } || {Key, Name} <- LookupFields ],
Terms = [ {Name, value_of(Key, Data, "unknown") } || {Key, Name} <- ldap_attribute_map() ],
Result = Terms ++ [ { <<"username">>, CanonicalUserName },
{ <<"external_authentication_uid">>, UserName },
{ <<"recovery_authentication_enabled">>, false } ],
{CN, CanonicalUserName, {Result}}.


ldap_attribute_map() ->
Config = envy:get(oc_chef_wm, ldap, list),
Map = [{proplists:get_value(display_name_attribute, Config, "displayname"), <<"display_name">>},
{proplists:get_value(first_name_attribute, Config, "givenname"), <<"first_name">>},
{proplists:get_value(last_name_attribute, Config, "sn"), <<"last_name">>},
{proplists:get_value(common_name_attribute, Config, "cn"), <<"common_name">>},
{proplists:get_value(country_attribute, Config, "c"), <<"country">>},
{proplists:get_value(city_attribute, Config, "l"), <<"city">>},
{proplists:get_value(email_attribute, Config, "mail"), <<"email">>}],
%% We call to_lower on all ldap field names in the ldap response,
%% thus we need to do the same on the field names provided by the
%% user via the configuration.
[ {string:to_lower(LdapAttr), ChefAttr} || {LdapAttr, ChefAttr} <- Map ].

close({ok, Session}) ->
eldap:close(Session);
close(_) ->
Expand Down
108 changes: 107 additions & 1 deletion src/oc_erchef/apps/oc_chef_wm/test/oc_chef_wm_authn_ldap_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,50 @@
-module(oc_chef_wm_authn_ldap_tests).
-include_lib("eunit/include/eunit.hrl").

-define(DEFAULT_CONFIG, [{host,"192.168.33.152"},
{port,389},
{timeout,60000},
{bind_dn,"cn=admin,dc=chef-server,dc=dev"},
{bind_password,"H0\\/\\/!|\\/|3tY0ur|\\/|0th3r"},
{base_dn,"ou=chefs,dc=chef-server,dc=dev"},
{group_dn,[]},
{login_attribute,"uid"},
{display_name_attribute,"displayname"},
{first_name_attribute,"givenname"},
{last_name_attribute,"sn"},
{common_name_attribute,"cn"},
{country_attribute,"c"},
{city_attribute,"l"},
{email_attribute,"mail"},
{case_sensitive_login_attribute,false},
{encryption,none}]).

-define(CUSTOM_CONFIG, [{host,"192.168.33.152"},
{port,389},
{timeout,60000},
{bind_dn,"cn=admin,dc=chef-server,dc=dev"},
{bind_password,"H0\\/\\/!|\\/|3tY0ur|\\/|0th3r"},
{base_dn,"ou=chefs,dc=chef-server,dc=dev"},
{group_dn,[]},
{login_attribute,"uid"},
%% This is changed from the default of 'displayname'
{display_name_attribute,"nomdeguerre"},
%% This is changed from the default of 'givenname'
{first_name_attribute,"nomdeplume"},
%% This is changed from the default of 'sn'
{last_name_attribute,"surname"},
%% This is changed from the default of 'cn'
{common_name_attribute,"uncommonname"},
%% This is changed from the default of 'c'
{country_attribute,"notc"},
%% This is changed from the default of 'l'
{city_attribute,"homebase"},
%% This is changed from the default of 'mail'
{email_attribute,"email"},
{case_sensitive_login_attribute,false},
{encryption,none}]).


value_of_test_() ->
Data = [{"key1", ["a_value"]}, {"key2", ["first", "second"]}],
[{"returns a scalar (binary) value for the given key in a proplist where the values are arrays",
Expand Down Expand Up @@ -60,6 +104,7 @@ canonical_username_test_() ->
].

result_to_user_ejson_test_() ->
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG),
LoginAttr = "uid",
UserName = <<"bob^bob">>,
LdapUser = [{eldap_entry, "uid=bob^bob,ou=Person,dc=example,dc=com",
Expand All @@ -73,6 +118,17 @@ result_to_user_ejson_test_() ->
{"o",["BigCorporation"]},
{"objectClass", ["person","organizationalPerson","inetOrgPerson"]},
{"uid",["bob^bob"]}]}],
StrangeLdapUser = [{eldap_entry, "uid=bob^bob,ou=Person,dc=example,dc=com",
[{"notc", ["USA"]},
{"homebase",["Seattle"]},
{"surname", ["Rabbit"]},
{"email", ["[email protected]"]},
{"nomdeplume",["Bob"]},
{"nomdeguerre", ["Bobby"]},
{"uncommonname", ["Bobby Bob"]},
{"o",["BigCorporation"]},
{"objectClass", ["person","organizationalPerson","inetOrgPerson"]},
{"uid",["bob^bob"]}]}],
LdapUserExtraUid = [{eldap_entry, "uid=bob^bob,ou=Person,dc=example,dc=com",
[{"c", ["USA"]},
{"l",["Seattle"]},
Expand Down Expand Up @@ -155,4 +211,54 @@ result_to_user_ejson_test_() ->
fun() ->
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,LdapUserExtraUid),
?assertEqual(<<"[email protected]">>, proplists:get_value(<<"email">>, RetUser))
end}].
end},
{"uses a non-default display_name field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"Bobby">>, proplists:get_value(<<"display_name">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end},
{"uses a non-default first_name field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"Bob">>, proplists:get_value(<<"first_name">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end},
{"uses a non-default last_name field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"Rabbit">>, proplists:get_value(<<"last_name">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end},
{"uses a non-default common_name field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"Bobby Bob">>, proplists:get_value(<<"common_name">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end},
{"uses a non-default country field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"USA">>, proplists:get_value(<<"country">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end},
{"uses a non-default city field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"Seattle">>, proplists:get_value(<<"city">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end},
{"uses a non-default mail field when configurd to",
fun() ->
application:set_env(oc_chef_wm, ldap, ?CUSTOM_CONFIG),
{_, _, {RetUser}} = oc_chef_wm_authn_ldap:result_to_user_ejson(LoginAttr,UserName,StrangeLdapUser),
?assertEqual(<<"[email protected]">>, proplists:get_value(<<"email">>, RetUser)),
application:set_env(oc_chef_wm, ldap, ?DEFAULT_CONFIG)
end}
].

0 comments on commit 925cbaa

Please sign in to comment.