Skip to content

Commit 93c6b6e

Browse files
Allow to use any ldap attribute as user id for batch role sync
1 parent 9e5cb18 commit 93c6b6e

File tree

7 files changed

+486
-2
lines changed

7 files changed

+486
-2
lines changed

fiat-ldap-v2/fiat-ldap-v2.gradle

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2017 Google, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License")
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
dependencies {
18+
implementation project(":fiat-roles")
19+
implementation project(":fiat-core")
20+
21+
implementation "org.apache.commons:commons-lang3"
22+
implementation "org.springframework.boot:spring-boot-autoconfigure"
23+
implementation "org.springframework.security:spring-security-ldap"
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2017 Google, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License")
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.netflix.spinnaker.fiat.config;
18+
19+
import java.text.MessageFormat;
20+
import lombok.Data;
21+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
22+
import org.springframework.boot.context.properties.ConfigurationProperties;
23+
import org.springframework.context.annotation.Bean;
24+
import org.springframework.context.annotation.Configuration;
25+
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
26+
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
27+
28+
@Configuration
29+
@ConditionalOnProperty(value = "auth.group-membership.service", havingValue = "ldapv2")
30+
public class LdapConfigV2 {
31+
32+
@Bean
33+
public SpringSecurityLdapTemplate springSecurityLdapTemplate(ConfigProps configProps) {
34+
DefaultSpringSecurityContextSource contextSource =
35+
new DefaultSpringSecurityContextSource(configProps.url);
36+
contextSource.setUserDn(configProps.managerDn);
37+
contextSource.setPassword(configProps.managerPassword);
38+
contextSource.afterPropertiesSet();
39+
SpringSecurityLdapTemplate template = new SpringSecurityLdapTemplate(contextSource);
40+
template.setIgnorePartialResultException(configProps.isIgnorePartialResultException());
41+
return template;
42+
}
43+
44+
@Data
45+
@Configuration
46+
@ConfigurationProperties("auth.group-membership.ldapv2")
47+
public static class ConfigProps {
48+
String url;
49+
String managerDn;
50+
String managerPassword;
51+
52+
String groupSearchBase = "";
53+
MessageFormat userDnPattern = new MessageFormat("uid={0},ou=users");
54+
String userSearchBase = "";
55+
String userIdAttributes = "";
56+
String userSearchFilter;
57+
String groupSearchFilter = "(uniqueMember={0})";
58+
String groupRoleAttributes = "cn";
59+
String groupUserAttributes = "";
60+
61+
int thresholdToUseGroupMembership = 100;
62+
63+
boolean ignorePartialResultException = false;
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/*
2+
* Copyright 2017 Google, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License")
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.netflix.spinnaker.fiat.roles.ldap;
18+
19+
import com.netflix.spinnaker.fiat.config.LdapConfigV2;
20+
import com.netflix.spinnaker.fiat.model.resources.Role;
21+
import com.netflix.spinnaker.fiat.permissions.ExternalUser;
22+
import com.netflix.spinnaker.fiat.roles.UserRolesProvider;
23+
import java.text.MessageFormat;
24+
import java.util.*;
25+
import java.util.stream.Collectors;
26+
import javax.naming.InvalidNameException;
27+
import javax.naming.Name;
28+
import javax.naming.NamingEnumeration;
29+
import javax.naming.NamingException;
30+
import javax.naming.directory.Attribute;
31+
import javax.naming.directory.Attributes;
32+
import javax.naming.ldap.LdapName;
33+
import lombok.Setter;
34+
import lombok.extern.slf4j.Slf4j;
35+
import org.apache.commons.lang3.StringUtils;
36+
import org.apache.commons.lang3.tuple.Pair;
37+
import org.springframework.beans.factory.annotation.Autowired;
38+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
39+
import org.springframework.dao.IncorrectResultSizeDataAccessException;
40+
import org.springframework.ldap.core.AttributesMapper;
41+
import org.springframework.ldap.core.DirContextOperations;
42+
import org.springframework.ldap.core.DistinguishedName;
43+
import org.springframework.ldap.support.LdapEncoder;
44+
import org.springframework.security.ldap.LdapUtils;
45+
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
46+
import org.springframework.stereotype.Component;
47+
48+
@Slf4j
49+
@Component
50+
@ConditionalOnProperty(value = "auth.group-membership.service", havingValue = "ldapv2")
51+
public class LdapUserRolesProviderV2 implements UserRolesProvider {
52+
53+
@Setter private SpringSecurityLdapTemplate ldapTemplate;
54+
55+
@Setter private LdapConfigV2.ConfigProps configProps;
56+
57+
public LdapUserRolesProviderV2(
58+
@Autowired SpringSecurityLdapTemplate ldapTemplate,
59+
@Autowired LdapConfigV2.ConfigProps configProps) {
60+
this.ldapTemplate = ldapTemplate;
61+
this.configProps = configProps;
62+
}
63+
64+
@Override
65+
public List<Role> loadRoles(ExternalUser user) {
66+
String userId = user.getId();
67+
68+
log.debug("loadRoles for user " + userId);
69+
if (StringUtils.isEmpty(configProps.getGroupSearchBase())) {
70+
return new ArrayList<>();
71+
}
72+
73+
String fullUserDn = getUserFullDn(userId);
74+
75+
if (fullUserDn == null) {
76+
// Likely a service account
77+
log.debug("fullUserDn is null for {}", userId);
78+
return new ArrayList<>();
79+
}
80+
81+
String[] params = new String[] {fullUserDn, userId};
82+
83+
if (log.isDebugEnabled()) {
84+
log.debug(
85+
new StringBuilder("Searching for groups using ")
86+
.append("\ngroupSearchBase: ")
87+
.append(configProps.getGroupSearchBase())
88+
.append("\ngroupSearchFilter: ")
89+
.append(configProps.getGroupSearchFilter())
90+
.append("\nparams: ")
91+
.append(StringUtils.join(params, " :: "))
92+
.append("\ngroupRoleAttributes: ")
93+
.append(configProps.getGroupRoleAttributes())
94+
.toString());
95+
}
96+
97+
// Copied from org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator.
98+
Set<String> userRoles =
99+
ldapTemplate.searchForSingleAttributeValues(
100+
configProps.getGroupSearchBase(),
101+
configProps.getGroupSearchFilter(),
102+
params,
103+
configProps.getGroupRoleAttributes());
104+
105+
if (log.isDebugEnabled()) {
106+
log.debug("Got roles for user " + userId + ": " + userRoles);
107+
}
108+
return userRoles.stream()
109+
.map(role -> new Role(role).setSource(Role.Source.LDAP))
110+
.collect(Collectors.toList());
111+
}
112+
113+
private class RoleFullDNtoUserRoleMapper implements AttributesMapper<Role> {
114+
@Override
115+
public Role mapFromAttributes(Attributes attrs) throws NamingException {
116+
return new Role(attrs.get(configProps.getGroupRoleAttributes()).get().toString())
117+
.setSource(Role.Source.LDAP);
118+
}
119+
}
120+
121+
private class UserRoleMapper implements AttributesMapper<Pair<String, Collection<Role>>> {
122+
@Override
123+
public Pair<String, Collection<Role>> mapFromAttributes(Attributes attrs)
124+
throws NamingException {
125+
List<Role> roles = new ArrayList<>();
126+
Attribute memberOfAttribute = attrs.get("memberOf");
127+
if (memberOfAttribute != null) {
128+
for (NamingEnumeration<?> memberOf = memberOfAttribute.getAll(); memberOf.hasMore(); ) {
129+
String roleDN = memberOf.next().toString();
130+
LdapName ln = org.springframework.ldap.support.LdapUtils.newLdapName(roleDN);
131+
String role =
132+
org.springframework.ldap.support.LdapUtils.getStringValue(
133+
ln, configProps.getGroupRoleAttributes());
134+
roles.add(new Role(role).setSource(Role.Source.LDAP));
135+
}
136+
}
137+
138+
return Pair.of(
139+
attrs.get(configProps.getUserIdAttributes()).get().toString().toLowerCase(), roles);
140+
}
141+
}
142+
143+
@Override
144+
public Map<String, Collection<Role>> multiLoadRoles(Collection<ExternalUser> users) {
145+
if (StringUtils.isEmpty(configProps.getGroupSearchBase())) {
146+
return new HashMap<>();
147+
}
148+
StringBuilder filter = new StringBuilder();
149+
filter.append("(|");
150+
users.forEach(
151+
u -> filter.append(MessageFormat.format(configProps.getUserSearchFilter(), u.getId())));
152+
filter.append(")");
153+
154+
Map<String, List<ExternalUser>> userIds =
155+
users.stream().collect(Collectors.groupingBy(e -> e.getId().toLowerCase()));
156+
List<Role> roles =
157+
ldapTemplate.search(
158+
configProps.getGroupSearchBase(),
159+
MessageFormat.format(configProps.getGroupSearchFilter(), "*", "*"),
160+
new RoleFullDNtoUserRoleMapper());
161+
162+
return ldapTemplate
163+
.search(configProps.getUserSearchBase(), filter.toString(), new UserRoleMapper())
164+
.stream()
165+
.flatMap(
166+
p -> {
167+
List<ExternalUser> sameUsers = userIds.get(p.getKey().toLowerCase());
168+
return sameUsers.stream()
169+
.flatMap(it -> p.getValue().stream().map(role -> Pair.of(it.getId(), role)));
170+
})
171+
.filter(p -> roles.contains(p.getValue()))
172+
.collect(
173+
Collectors.groupingBy(
174+
Pair::getKey,
175+
Collectors.mapping(Pair::getValue, Collectors.toCollection(ArrayList::new))));
176+
}
177+
178+
private String getUserFullDn(String userId) {
179+
String rootDn = LdapUtils.parseRootDnFromUrl(configProps.getUrl());
180+
DistinguishedName root = new DistinguishedName(rootDn);
181+
log.debug("Root DN: " + root.toString());
182+
183+
String[] formatArgs = new String[] {LdapEncoder.nameEncode(userId)};
184+
185+
String partialUserDn;
186+
if (!StringUtils.isEmpty(configProps.getUserSearchFilter())) {
187+
try {
188+
DirContextOperations res =
189+
ldapTemplate.searchForSingleEntry(
190+
configProps.getUserSearchBase(), configProps.getUserSearchFilter(), formatArgs);
191+
partialUserDn = res.getDn().toString();
192+
} catch (IncorrectResultSizeDataAccessException e) {
193+
log.error("Unable to find a single user entry", e);
194+
return null;
195+
}
196+
} else {
197+
partialUserDn = configProps.getUserDnPattern().format(formatArgs);
198+
}
199+
200+
DistinguishedName user = new DistinguishedName(partialUserDn);
201+
log.debug("User portion: " + user.toString());
202+
203+
try {
204+
Name fullUser = root.addAll(user);
205+
log.debug("Full user DN: " + fullUser.toString());
206+
return fullUser.toString();
207+
} catch (InvalidNameException ine) {
208+
log.error("Could not assemble full userDn", ine);
209+
}
210+
return null;
211+
}
212+
}

0 commit comments

Comments
 (0)