Skip to content

Commit 6da8a63

Browse files
Refactor roles/sync and create new module for backward compatibility
1 parent e8a17cb commit 6da8a63

File tree

7 files changed

+337
-57
lines changed

7 files changed

+337
-57
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.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+
log.debug("Got roles for user " + userId + ": " + userRoles);
107+
return userRoles.stream()
108+
.map(role -> new Role(role).setSource(Role.Source.LDAP))
109+
.collect(Collectors.toList());
110+
}
111+
112+
private class RoleFullDNtoUserRoleMapper implements AttributesMapper<Role> {
113+
@Override
114+
public Role mapFromAttributes(Attributes attrs) throws NamingException {
115+
return new Role(attrs.get(configProps.getGroupRoleAttributes()).get().toString())
116+
.setSource(Role.Source.LDAP);
117+
}
118+
}
119+
120+
private class UserRoleMapper implements AttributesMapper<Pair<String, Collection<Role>>> {
121+
@Override
122+
public Pair<String, Collection<Role>> mapFromAttributes(Attributes attrs)
123+
throws NamingException {
124+
List<Role> roles = new ArrayList<>();
125+
Attribute memberOfAttribute = attrs.get("memberOf");
126+
if (memberOfAttribute != null) {
127+
for (NamingEnumeration<?> memberOf = memberOfAttribute.getAll(); memberOf.hasMore(); ) {
128+
String roleDN = memberOf.next().toString();
129+
LdapName ln = org.springframework.ldap.support.LdapUtils.newLdapName(roleDN);
130+
String role =
131+
org.springframework.ldap.support.LdapUtils.getStringValue(
132+
ln, configProps.getGroupRoleAttributes());
133+
roles.add(new Role(role).setSource(Role.Source.LDAP));
134+
}
135+
}
136+
137+
return Pair.of(
138+
attrs.get(configProps.getUserIdAttributes()).get().toString().toLowerCase(), roles);
139+
}
140+
}
141+
142+
@Override
143+
public Map<String, Collection<Role>> multiLoadRoles(Collection<ExternalUser> users) {
144+
if (StringUtils.isEmpty(configProps.getGroupSearchBase())) {
145+
return new HashMap<>();
146+
}
147+
StringBuilder filter = new StringBuilder();
148+
filter.append("(|");
149+
users.forEach(
150+
u -> filter.append(MessageFormat.format(configProps.getUserSearchFilter(), u.getId())));
151+
filter.append(")");
152+
153+
Map<String, String> userIds =
154+
users.stream()
155+
.map(ExternalUser::getId)
156+
.collect(Collectors.toMap(String::toLowerCase, Function.identity(), (a1, a2) -> a1));
157+
List<Role> roles =
158+
ldapTemplate.search(
159+
configProps.getGroupSearchBase(),
160+
MessageFormat.format(configProps.getGroupSearchFilter(), "*", "*"),
161+
new RoleFullDNtoUserRoleMapper());
162+
163+
return ldapTemplate
164+
.search(configProps.getUserSearchBase(), filter.toString(), new UserRoleMapper())
165+
.stream()
166+
.flatMap(
167+
p -> {
168+
String userId = userIds.get(p.getKey());
169+
return p.getValue().stream().map(it -> Pair.of(userId, it));
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+
}
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/test/groovy/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderTest.groovy

-55
Original file line numberDiff line numberDiff line change
@@ -86,61 +86,6 @@ class LdapUserRolesProviderTest extends Specification {
8686
"notEmpty" |_
8787
}
8888

89-
void "multiLoadRoles should use loadRoles when groupUserAttributes is empty"() {
90-
given:
91-
def users = [externalUser("user1"), externalUser("user2")]
92-
def role1 = new Role("group1")
93-
def role2 = new Role("group2")
94-
95-
def configProps = baseConfigProps()
96-
def provider = Spy(LdapUserRolesProvider){
97-
loadRoles(_ as ExternalUser) >>> [[role1], [role2]]
98-
}.setConfigProps(configProps)
99-
100-
when:
101-
configProps.groupSearchBase = ""
102-
def roles = provider.multiLoadRoles(users)
103-
104-
then:
105-
roles == [:]
106-
107-
when:
108-
configProps.groupSearchBase = "notEmpty"
109-
roles = provider.multiLoadRoles(users)
110-
111-
then:
112-
roles == [user1: [role1], user2: [role2]]
113-
}
114-
115-
void "multiLoadRoles should use groupUserAttributes when groupUserAttributes is not empty"() {
116-
given:
117-
def users = [externalUser("user1"), externalUser("user2")]
118-
def role1 = new Role("group1")
119-
def role2 = new Role("group2")
120-
121-
def configProps = baseConfigProps().setGroupSearchBase("notEmpty").setGroupUserAttributes("member")
122-
def provider = Spy(LdapUserRolesProvider){
123-
2 * loadRoles(_) >>> [[role1], [role2]]
124-
}.setConfigProps(configProps)
125-
126-
when: "thresholdToUseGroupMembership is too high"
127-
configProps.thresholdToUseGroupMembership = 100
128-
def roles = provider.multiLoadRoles(users)
129-
130-
then: "should use loadRoles"
131-
roles == [user1: [role1], user2: [role2]]
132-
133-
when: "users count is greater than thresholdToUseGroupMembership"
134-
configProps.thresholdToUseGroupMembership = 1
135-
provider.ldapTemplate = Mock(SpringSecurityLdapTemplate) {
136-
1 * search(*_) >> [[Pair.of("user1",role1)], [Pair.of("user2", role2)], [Pair.of("unknown", role2)]]
137-
}
138-
roles = provider.multiLoadRoles(users)
139-
140-
then: "should use ldapTemplate.search method"
141-
roles == [user1: [role1], user2: [role2]]
142-
}
143-
14489
private static ExternalUser externalUser(String id) {
14590
return new ExternalUser().setId(id)
14691
}

0 commit comments

Comments
 (0)