diff --git a/fiat-ldap-v2/fiat-ldap-v2.gradle b/fiat-ldap-v2/fiat-ldap-v2.gradle new file mode 100644 index 000000000..47aa62cde --- /dev/null +++ b/fiat-ldap-v2/fiat-ldap-v2.gradle @@ -0,0 +1,24 @@ +/* + * Copyright 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +dependencies { + implementation project(":fiat-roles") + implementation project(":fiat-core") + + implementation "org.apache.commons:commons-lang3" + implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation "org.springframework.security:spring-security-ldap" +} diff --git a/fiat-ldap-v2/src/main/java/com/netflix/spinnaker/fiat/config/LdapConfigV2.java b/fiat-ldap-v2/src/main/java/com/netflix/spinnaker/fiat/config/LdapConfigV2.java new file mode 100644 index 000000000..691628b4b --- /dev/null +++ b/fiat-ldap-v2/src/main/java/com/netflix/spinnaker/fiat/config/LdapConfigV2.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +package com.netflix.spinnaker.fiat.config; + +import java.text.MessageFormat; +import lombok.Data; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.ldap.DefaultSpringSecurityContextSource; +import org.springframework.security.ldap.SpringSecurityLdapTemplate; + +@Configuration +@ConditionalOnProperty(value = "auth.group-membership.service", havingValue = "ldapv2") +public class LdapConfigV2 { + + @Bean + public SpringSecurityLdapTemplate springSecurityLdapTemplate(ConfigProps configProps) { + DefaultSpringSecurityContextSource contextSource = + new DefaultSpringSecurityContextSource(configProps.url); + contextSource.setUserDn(configProps.managerDn); + contextSource.setPassword(configProps.managerPassword); + contextSource.afterPropertiesSet(); + SpringSecurityLdapTemplate template = new SpringSecurityLdapTemplate(contextSource); + template.setIgnorePartialResultException(configProps.isIgnorePartialResultException()); + return template; + } + + @Data + @Configuration + @ConfigurationProperties("auth.group-membership.ldapv2") + public static class ConfigProps { + String url; + String managerDn; + String managerPassword; + + String groupSearchBase = ""; + MessageFormat userDnPattern = new MessageFormat("uid={0},ou=users"); + String userSearchBase = ""; + String userIdAttributes = ""; + String userSearchFilter; + String groupSearchFilter = "(uniqueMember={0})"; + String groupRoleAttributes = "cn"; + String groupUserAttributes = ""; + + int thresholdToUseGroupMembership = 100; + + boolean ignorePartialResultException = false; + } +} diff --git a/fiat-ldap-v2/src/main/java/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderV2.java b/fiat-ldap-v2/src/main/java/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderV2.java new file mode 100644 index 000000000..0d94fe0cd --- /dev/null +++ b/fiat-ldap-v2/src/main/java/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderV2.java @@ -0,0 +1,212 @@ +/* + * Copyright 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +package com.netflix.spinnaker.fiat.roles.ldap; + +import com.netflix.spinnaker.fiat.config.LdapConfigV2; +import com.netflix.spinnaker.fiat.model.resources.Role; +import com.netflix.spinnaker.fiat.permissions.ExternalUser; +import com.netflix.spinnaker.fiat.roles.UserRolesProvider; +import java.text.MessageFormat; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.naming.InvalidNameException; +import javax.naming.Name; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.ldap.LdapName; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.ldap.core.AttributesMapper; +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.ldap.core.DistinguishedName; +import org.springframework.ldap.support.LdapEncoder; +import org.springframework.security.ldap.LdapUtils; +import org.springframework.security.ldap.SpringSecurityLdapTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(value = "auth.group-membership.service", havingValue = "ldapv2") +public class LdapUserRolesProviderV2 implements UserRolesProvider { + + @Setter private SpringSecurityLdapTemplate ldapTemplate; + + @Setter private LdapConfigV2.ConfigProps configProps; + + public LdapUserRolesProviderV2( + @Autowired SpringSecurityLdapTemplate ldapTemplate, + @Autowired LdapConfigV2.ConfigProps configProps) { + this.ldapTemplate = ldapTemplate; + this.configProps = configProps; + } + + @Override + public List loadRoles(ExternalUser user) { + String userId = user.getId(); + + log.debug("loadRoles for user " + userId); + if (StringUtils.isEmpty(configProps.getGroupSearchBase())) { + return new ArrayList<>(); + } + + String fullUserDn = getUserFullDn(userId); + + if (fullUserDn == null) { + // Likely a service account + log.debug("fullUserDn is null for {}", userId); + return new ArrayList<>(); + } + + String[] params = new String[] {fullUserDn, userId}; + + if (log.isDebugEnabled()) { + log.debug( + new StringBuilder("Searching for groups using ") + .append("\ngroupSearchBase: ") + .append(configProps.getGroupSearchBase()) + .append("\ngroupSearchFilter: ") + .append(configProps.getGroupSearchFilter()) + .append("\nparams: ") + .append(StringUtils.join(params, " :: ")) + .append("\ngroupRoleAttributes: ") + .append(configProps.getGroupRoleAttributes()) + .toString()); + } + + // Copied from org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator. + Set userRoles = + ldapTemplate.searchForSingleAttributeValues( + configProps.getGroupSearchBase(), + configProps.getGroupSearchFilter(), + params, + configProps.getGroupRoleAttributes()); + + log.debug("Got roles for user " + userId + ": " + userRoles); + return userRoles.stream() + .map(role -> new Role(role).setSource(Role.Source.LDAP)) + .collect(Collectors.toList()); + } + + private class RoleFullDNtoUserRoleMapper implements AttributesMapper { + @Override + public Role mapFromAttributes(Attributes attrs) throws NamingException { + return new Role(attrs.get(configProps.getGroupRoleAttributes()).get().toString()) + .setSource(Role.Source.LDAP); + } + } + + private class UserRoleMapper implements AttributesMapper>> { + @Override + public Pair> mapFromAttributes(Attributes attrs) + throws NamingException { + List roles = new ArrayList<>(); + Attribute memberOfAttribute = attrs.get("memberOf"); + if (memberOfAttribute != null) { + for (NamingEnumeration memberOf = memberOfAttribute.getAll(); memberOf.hasMore(); ) { + String roleDN = memberOf.next().toString(); + LdapName ln = org.springframework.ldap.support.LdapUtils.newLdapName(roleDN); + String role = + org.springframework.ldap.support.LdapUtils.getStringValue( + ln, configProps.getGroupRoleAttributes()); + roles.add(new Role(role).setSource(Role.Source.LDAP)); + } + } + + return Pair.of( + attrs.get(configProps.getUserIdAttributes()).get().toString().toLowerCase(), roles); + } + } + + @Override + public Map> multiLoadRoles(Collection users) { + if (StringUtils.isEmpty(configProps.getGroupSearchBase())) { + return new HashMap<>(); + } + StringBuilder filter = new StringBuilder(); + filter.append("(|"); + users.forEach( + u -> filter.append(MessageFormat.format(configProps.getUserSearchFilter(), u.getId()))); + filter.append(")"); + + Map userIds = + users.stream() + .map(ExternalUser::getId) + .collect(Collectors.toMap(String::toLowerCase, Function.identity(), (a1, a2) -> a1)); + List roles = + ldapTemplate.search( + configProps.getGroupSearchBase(), + MessageFormat.format(configProps.getGroupSearchFilter(), "*", "*"), + new RoleFullDNtoUserRoleMapper()); + + return ldapTemplate + .search(configProps.getUserSearchBase(), filter.toString(), new UserRoleMapper()) + .stream() + .flatMap( + p -> { + String userId = userIds.get(p.getKey()); + return p.getValue().stream().map(it -> Pair.of(userId, it)); + }) + .filter(p -> roles.contains(p.getValue())) + .collect( + Collectors.groupingBy( + Pair::getKey, + Collectors.mapping(Pair::getValue, Collectors.toCollection(ArrayList::new)))); + } + + private String getUserFullDn(String userId) { + String rootDn = LdapUtils.parseRootDnFromUrl(configProps.getUrl()); + DistinguishedName root = new DistinguishedName(rootDn); + log.debug("Root DN: " + root.toString()); + + String[] formatArgs = new String[] {LdapEncoder.nameEncode(userId)}; + + String partialUserDn; + if (!StringUtils.isEmpty(configProps.getUserSearchFilter())) { + try { + DirContextOperations res = + ldapTemplate.searchForSingleEntry( + configProps.getUserSearchBase(), configProps.getUserSearchFilter(), formatArgs); + partialUserDn = res.getDn().toString(); + } catch (IncorrectResultSizeDataAccessException e) { + log.error("Unable to find a single user entry", e); + return null; + } + } else { + partialUserDn = configProps.getUserDnPattern().format(formatArgs); + } + + DistinguishedName user = new DistinguishedName(partialUserDn); + log.debug("User portion: " + user.toString()); + + try { + Name fullUser = root.addAll(user); + log.debug("Full user DN: " + fullUser.toString()); + return fullUser.toString(); + } catch (InvalidNameException ine) { + log.error("Could not assemble full userDn", ine); + } + return null; + } +} diff --git a/fiat-ldap-v2/src/test/groovy/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderTest.groovy b/fiat-ldap-v2/src/test/groovy/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderTest.groovy new file mode 100644 index 000000000..a3986ad99 --- /dev/null +++ b/fiat-ldap-v2/src/test/groovy/com/netflix/spinnaker/fiat/roles/ldap/LdapUserRolesProviderTest.groovy @@ -0,0 +1,33 @@ +/* + * Copyright 2018 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +package com.netflix.spinnaker.fiat.roles.ldap + +import com.netflix.spinnaker.fiat.config.LdapConfigV2 +import com.netflix.spinnaker.fiat.model.resources.Role +import com.netflix.spinnaker.fiat.permissions.ExternalUser +import org.springframework.dao.IncorrectResultSizeDataAccessException +import org.springframework.security.ldap.SpringSecurityLdapTemplate +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll +import org.apache.commons.lang3.tuple.Pair + +class LdapUserRolesProviderTest extends Specification { + + +} diff --git a/gradle.properties b/gradle.properties index 9df38d59b..5639fabbe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -includeProviders=file,github,google-groups,ldap +includeProviders=file,github,google-groups,ldap,ldap-v2 korkVersion=7.99.1 org.gradle.parallel=true spinnakerGradleVersion=8.10.1 diff --git a/settings.gradle b/settings.gradle index cf425f4fb..4fa363f48 100644 --- a/settings.gradle +++ b/settings.gradle @@ -43,7 +43,8 @@ include 'fiat-api', 'fiat-google-groups', 'fiat-ldap', 'fiat-roles', - 'fiat-web' + 'fiat-web', + 'fiat-ldap-v2' def setBuildFile(project) { project.buildFileName = "${project.name}.gradle"