Skip to content

Commit d32578a

Browse files
Allow to use any ldap attribute as user id for batch role sync
1 parent 6d62aec commit d32578a

File tree

7 files changed

+341
-2
lines changed

7 files changed

+341
-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,214 @@
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.function.Function;
26+
import java.util.stream.Collectors;
27+
import javax.naming.InvalidNameException;
28+
import javax.naming.Name;
29+
import javax.naming.NamingEnumeration;
30+
import javax.naming.NamingException;
31+
import javax.naming.directory.Attribute;
32+
import javax.naming.directory.Attributes;
33+
import javax.naming.ldap.LdapName;
34+
import lombok.Setter;
35+
import lombok.extern.slf4j.Slf4j;
36+
import org.apache.commons.lang3.StringUtils;
37+
import org.apache.commons.lang3.tuple.Pair;
38+
import org.springframework.beans.factory.annotation.Autowired;
39+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
40+
import org.springframework.dao.IncorrectResultSizeDataAccessException;
41+
import org.springframework.ldap.core.AttributesMapper;
42+
import org.springframework.ldap.core.DirContextOperations;
43+
import org.springframework.ldap.core.DistinguishedName;
44+
import org.springframework.ldap.support.LdapEncoder;
45+
import org.springframework.security.ldap.LdapUtils;
46+
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
47+
import org.springframework.stereotype.Component;
48+
49+
@Slf4j
50+
@Component
51+
@ConditionalOnProperty(value = "auth.group-membership.service", havingValue = "ldapv2")
52+
public class LdapUserRolesProviderV2 implements UserRolesProvider {
53+
54+
@Setter private SpringSecurityLdapTemplate ldapTemplate;
55+
56+
@Setter private LdapConfigV2.ConfigProps configProps;
57+
58+
public LdapUserRolesProviderV2(
59+
@Autowired SpringSecurityLdapTemplate ldapTemplate,
60+
@Autowired LdapConfigV2.ConfigProps configProps) {
61+
this.ldapTemplate = ldapTemplate;
62+
this.configProps = configProps;
63+
}
64+
65+
@Override
66+
public List<Role> loadRoles(ExternalUser user) {
67+
String userId = user.getId();
68+
69+
log.debug("loadRoles for user " + userId);
70+
if (StringUtils.isEmpty(configProps.getGroupSearchBase())) {
71+
return new ArrayList<>();
72+
}
73+
74+
String fullUserDn = getUserFullDn(userId);
75+
76+
if (fullUserDn == null) {
77+
// Likely a service account
78+
log.debug("fullUserDn is null for {}", userId);
79+
return new ArrayList<>();
80+
}
81+
82+
String[] params = new String[] {fullUserDn, userId};
83+
84+
if (log.isDebugEnabled()) {
85+
log.debug(
86+
new StringBuilder("Searching for groups using ")
87+
.append("\ngroupSearchBase: ")
88+
.append(configProps.getGroupSearchBase())
89+
.append("\ngroupSearchFilter: ")
90+
.append(configProps.getGroupSearchFilter())
91+
.append("\nparams: ")
92+
.append(StringUtils.join(params, " :: "))
93+
.append("\ngroupRoleAttributes: ")
94+
.append(configProps.getGroupRoleAttributes())
95+
.toString());
96+
}
97+
98+
// Copied from org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator.
99+
Set<String> userRoles =
100+
ldapTemplate.searchForSingleAttributeValues(
101+
configProps.getGroupSearchBase(),
102+
configProps.getGroupSearchFilter(),
103+
params,
104+
configProps.getGroupRoleAttributes());
105+
106+
if (log.isDebugEnabled()) {
107+
log.debug("Got roles for user " + userId + ": " + userRoles);
108+
}
109+
return userRoles.stream()
110+
.map(role -> new Role(role).setSource(Role.Source.LDAP))
111+
.collect(Collectors.toList());
112+
}
113+
114+
private class RoleFullDNtoUserRoleMapper implements AttributesMapper<Role> {
115+
@Override
116+
public Role mapFromAttributes(Attributes attrs) throws NamingException {
117+
return new Role(attrs.get(configProps.getGroupRoleAttributes()).get().toString())
118+
.setSource(Role.Source.LDAP);
119+
}
120+
}
121+
122+
private class UserRoleMapper implements AttributesMapper<Pair<String, Collection<Role>>> {
123+
@Override
124+
public Pair<String, Collection<Role>> mapFromAttributes(Attributes attrs)
125+
throws NamingException {
126+
List<Role> roles = new ArrayList<>();
127+
Attribute memberOfAttribute = attrs.get("memberOf");
128+
if (memberOfAttribute != null) {
129+
for (NamingEnumeration<?> memberOf = memberOfAttribute.getAll(); memberOf.hasMore(); ) {
130+
String roleDN = memberOf.next().toString();
131+
LdapName ln = org.springframework.ldap.support.LdapUtils.newLdapName(roleDN);
132+
String role =
133+
org.springframework.ldap.support.LdapUtils.getStringValue(
134+
ln, configProps.getGroupRoleAttributes());
135+
roles.add(new Role(role).setSource(Role.Source.LDAP));
136+
}
137+
}
138+
139+
return Pair.of(
140+
attrs.get(configProps.getUserIdAttributes()).get().toString().toLowerCase(), roles);
141+
}
142+
}
143+
144+
@Override
145+
public Map<String, Collection<Role>> multiLoadRoles(Collection<ExternalUser> users) {
146+
if (StringUtils.isEmpty(configProps.getGroupSearchBase())) {
147+
return new HashMap<>();
148+
}
149+
StringBuilder filter = new StringBuilder();
150+
filter.append("(|");
151+
users.forEach(
152+
u -> filter.append(MessageFormat.format(configProps.getUserSearchFilter(), u.getId())));
153+
filter.append(")");
154+
155+
Map<String, String> userIds =
156+
users.stream()
157+
.map(ExternalUser::getId)
158+
.collect(Collectors.toMap(String::toLowerCase, Function.identity(), (a1, a2) -> a1));
159+
List<Role> roles =
160+
ldapTemplate.search(
161+
configProps.getGroupSearchBase(),
162+
MessageFormat.format(configProps.getGroupSearchFilter(), "*", "*"),
163+
new RoleFullDNtoUserRoleMapper());
164+
165+
return ldapTemplate
166+
.search(configProps.getUserSearchBase(), filter.toString(), new UserRoleMapper())
167+
.stream()
168+
.flatMap(
169+
p -> {
170+
String userId = userIds.get(p.getKey());
171+
return p.getValue().stream().map(it -> Pair.of(userId, it));
172+
})
173+
.filter(p -> roles.contains(p.getValue()))
174+
.collect(
175+
Collectors.groupingBy(
176+
Pair::getKey,
177+
Collectors.mapping(Pair::getValue, Collectors.toCollection(ArrayList::new))));
178+
}
179+
180+
private String getUserFullDn(String userId) {
181+
String rootDn = LdapUtils.parseRootDnFromUrl(configProps.getUrl());
182+
DistinguishedName root = new DistinguishedName(rootDn);
183+
log.debug("Root DN: " + root.toString());
184+
185+
String[] formatArgs = new String[] {LdapEncoder.nameEncode(userId)};
186+
187+
String partialUserDn;
188+
if (!StringUtils.isEmpty(configProps.getUserSearchFilter())) {
189+
try {
190+
DirContextOperations res =
191+
ldapTemplate.searchForSingleEntry(
192+
configProps.getUserSearchBase(), configProps.getUserSearchFilter(), formatArgs);
193+
partialUserDn = res.getDn().toString();
194+
} catch (IncorrectResultSizeDataAccessException e) {
195+
log.error("Unable to find a single user entry", e);
196+
return null;
197+
}
198+
} else {
199+
partialUserDn = configProps.getUserDnPattern().format(formatArgs);
200+
}
201+
202+
DistinguishedName user = new DistinguishedName(partialUserDn);
203+
log.debug("User portion: " + user.toString());
204+
205+
try {
206+
Name fullUser = root.addAll(user);
207+
log.debug("Full user DN: " + fullUser.toString());
208+
return fullUser.toString();
209+
} catch (InvalidNameException ine) {
210+
log.error("Could not assemble full userDn", ine);
211+
}
212+
return null;
213+
}
214+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2018 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 org.springframework.dao.IncorrectResultSizeDataAccessException
23+
import org.springframework.security.ldap.SpringSecurityLdapTemplate
24+
import spock.lang.Shared
25+
import spock.lang.Specification
26+
import spock.lang.Subject
27+
import spock.lang.Unroll
28+
import org.apache.commons.lang3.tuple.Pair
29+
30+
class LdapUserRolesProviderTest extends Specification {
31+
32+
33+
}

fiat-ldap/src/main/java/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProvider.java

+3-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ public List<Role> loadRoles(ExternalUser user) {
9494
params,
9595
configProps.getGroupRoleAttributes());
9696

97-
log.debug("Got roles for user " + userId + ": " + userRoles);
97+
if (log.isDebugEnabled()) {
98+
log.debug("Got roles for user " + userId + ": " + userRoles);
99+
}
98100
return userRoles.stream()
99101
.map(role -> new Role(role).setSource(Role.Source.LDAP))
100102
.collect(Collectors.toList());

gradle.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
includeProviders=file,github,google-groups,ldap
1+
includeProviders=file,github,google-groups,ldap,ldap-v2
22
korkVersion=7.107.0
33
org.gradle.parallel=true
44
spinnakerGradleVersion=8.11.0

settings.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ include 'fiat-api',
4242
'fiat-github',
4343
'fiat-google-groups',
4444
'fiat-ldap',
45+
'fiat-ldap-v2',
4546
'fiat-roles',
4647
'fiat-sql',
4748
'fiat-sql-mysql',

0 commit comments

Comments
 (0)