OpencastLdapAuthoritiesPopulator.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community 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://opensource.org/licenses/ecl2.txt
 *
 * 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 org.opencastproject.userdirectory.ldap;

import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.Role;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.userdirectory.JpaGroupRoleProvider;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/** Map a series of LDAP attributes to user authorities in Opencast */
public class OpencastLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator {

  public static final String ROLE_CLEAN_REGEXP = "[\\s_]+";
  public static final String ROLE_CLEAN_REPLACEMENT = "_";

  private Set<String> attributeNames;
  private String[] additionalAuthorities;
  private String prefix = "";
  private Set<String> excludedPrefixes = new HashSet<>();
  private String groupCheckPrefix = null;
  private boolean applyAttributesAsRoles = true;
  private boolean applyAttributesAsGroups = true;
  private Map<String, String[]> ldapAssignmentRoleMap = new HashMap<>();
  private Map<String, String[]> ldapAssignmentGroupMap = new HashMap<>();
  private boolean uppercase = true;
  private Organization organization;
  private SecurityService securityService;
  private JpaGroupRoleProvider groupRoleProvider;
  private static final Logger logger = LoggerFactory.getLogger(OpencastLdapAuthoritiesPopulator.class);

  /**
   * Activate component
   *
   * @param applyAttributesAsRoles
   *          Specifies, whether the ldap attributes should be added as a role.
   * @param applyAttributesAsGroups
   *          Specifies, whether the ldap attributes should be added as a group.
   *          applyAttributesAsRoles needs to be enabled.
   * @param ldapAssignmentRoleMap
   *          Maps the ldap assignments to additional roles.
   *          Key and value are expected to be uppercase if the bool uppercase is set.
   * @param ldapAssignmentGroupMap
   *          Maps the ldap assignments to additional groups.
   *          Key and value are expected to be uppercase if the bool uppercase is set.
   */
  public OpencastLdapAuthoritiesPopulator(
      String attributeNames,
      String prefix,
      String[] aExcludedPrefixes,
      String groupCheckPrefix,
      boolean applyAttributesAsRoles,
      boolean applyAttributesAsGroups,
      Map<String, String[]> ldapAssignmentRoleMap,
      Map<String, String[]> ldapAssignmentGroupMap,
      boolean uppercase,
      Organization organization,
      SecurityService securityService,
      JpaGroupRoleProvider groupRoleProvider,
      String... additionalAuthorities
  ) {

    logger.debug("Creating new instance");

    if (attributeNames == null) {
      throw new IllegalArgumentException("The attribute list cannot be null");
    }

    if (securityService == null) {
      throw new IllegalArgumentException("The security service cannot be null");
    }
    this.securityService = securityService;

    if (organization == null) {
      throw new IllegalArgumentException("The organization cannot be null");
    }
    this.organization = organization;

    this.attributeNames = new HashSet<>();
    for (String attributeName : attributeNames.split(",")) {
      String temp = attributeName.trim();
      if (!temp.isEmpty()) {
        this.attributeNames.add(temp);
      }
    }
    if (this.attributeNames.size() == 0) {
      throw new IllegalArgumentException("At least one valid attribute must be provided");
    }

    if (logger.isDebugEnabled()) {
      logger.debug("Roles will be read from the LDAP attributes:");
      for (String attribute : this.attributeNames) {
        logger.debug("\t* {}", attribute);
      }
    }

    if (groupRoleProvider == null) {
      logger.info("Provided GroupRoleProvider was null. Group roles will therefore not be expanded");
    }
    this.groupRoleProvider = groupRoleProvider;

    this.uppercase = uppercase;
    if (uppercase) {
      logger.debug("Roles will be converted to uppercase");
    } else {
      logger.debug("Roles will NOT be converted to uppercase");
    }

    this.prefix = roleCleanUpperCase(prefix, uppercase);
    logger.debug("Role prefix set to: {}", this.prefix);

    if (aExcludedPrefixes != null) {
      for (String origExcludedPrefix : aExcludedPrefixes) {
        String excludedPrefix;
        if (uppercase) {
          excludedPrefix = StringUtils.trimToEmpty(origExcludedPrefix).toUpperCase();
        } else {
          excludedPrefix = StringUtils.trimToEmpty(origExcludedPrefix);
        }
        if (!excludedPrefix.isEmpty()) {
          excludedPrefixes.add(excludedPrefix);
        }
      }
    }

    if (groupCheckPrefix == null) {
      throw new IllegalArgumentException("The parameter groupCheckPrefix cannot be null");
    }
    this.groupCheckPrefix = groupCheckPrefix;
    if (uppercase) {
      this.groupCheckPrefix = this.groupCheckPrefix.toUpperCase();
    }

    this.applyAttributesAsRoles = applyAttributesAsRoles;
    this.applyAttributesAsGroups = applyAttributesAsGroups;

    if (ldapAssignmentRoleMap != null) {
      this.ldapAssignmentRoleMap = ldapAssignmentRoleMap;
    }

    if (ldapAssignmentGroupMap != null) {
      this.ldapAssignmentGroupMap = ldapAssignmentGroupMap;
    }

    if (additionalAuthorities == null) {
      this.additionalAuthorities = new String[0];
    } else {
      this.additionalAuthorities = Arrays.stream(additionalAuthorities)
              .map(x -> roleCleanUpperCase(x, uppercase))
              .toArray(String[]::new);
    }

    if (logger.isDebugEnabled()) {
      StringBuilder additionalAuthoritiesAsStr = new StringBuilder();
      for (String role : this.additionalAuthorities) {
        additionalAuthoritiesAsStr.append(String.format("\n\t* %s", role));
      }
      logger.debug("Authenticated users will receive the following extra roles:{}", additionalAuthoritiesAsStr);
    }
  }

  @Override
  public Collection<? extends GrantedAuthority> getGrantedAuthorities(DirContextOperations userData, String username) {

    logger.debug("user attributes for user {}:\n\t{}", username, userData.getAttributes());

    Set<GrantedAuthority> authorities = new HashSet<>();
    for (String attributeName : attributeNames) {
      logger.debug("Looking for attribute name '{}'", attributeName);
      try {
        String[] attributeValues = userData.getStringAttributes(attributeName);
        // Should the attribute not be defined, the returned array is null
        if (attributeValues != null) {
          for (String attributeValue : attributeValues) {
            // The attribute value may be a single authority (a single role) or a list of roles
            String[] splitValue =  attributeValue.split(",");
            if (applyAttributesAsRoles) {
              String[] roles = splitValue;
              addAuthorities(authorities, roles, false, true);
              if (applyAttributesAsGroups) {
                // ignore attributes which aren't groups according to groupCheckPrefix
                String[] groups = Arrays.stream(splitValue)
                        .filter(x -> {
                          String filter = roleCleanUpperCase(x, uppercase);
                          return filter.startsWith(groupCheckPrefix);
                        })
                        .toArray(String[]::new);
                addAuthorities(authorities, groups, true, true);
              }
            }

            // map attribute values to roles
            String[] mappedRoles = Arrays.stream(splitValue)
                     .map(x -> roleCleanUpperCase(x, uppercase))
                     .map(x -> ldapAssignmentRoleMap.get(x))
                     .filter(x -> x != null)
                     .flatMap(x -> Arrays.stream(x))
                     .toArray(String[]::new);
            addAuthorities(authorities, mappedRoles, false, false);
            // map attribute values to groups
            String[] mappedGroups = Arrays.stream(splitValue)
                    .map(x -> roleCleanUpperCase(x, uppercase))
                    .map(x -> ldapAssignmentGroupMap.get(x))
                    .filter(x -> x != null)
                    .flatMap(x -> Arrays.stream(x))
                    .toArray(String[]::new);
            addAuthorities(authorities, mappedGroups, true, false);
          }
        } else {
          logger.debug("Could not find any attribute named '{}' in user '{}'", attributeName, userData.getDn());
        }
      } catch (ClassCastException e) {
        logger.error(
            "Specified attribute containing user roles ('{}') was not of expected type String",
            attributeName, e);
      }
    }

    // Add the list of additional roles
    addAuthorities(authorities, additionalAuthorities, false, false);
    addAuthorities(authorities, Arrays.stream(additionalAuthorities)
        .filter(x -> x.startsWith(groupCheckPrefix))
        .toArray(String[]::new),
        true, false
    );

    if (logger.isDebugEnabled()) {
      StringBuilder authorityListAsString = new StringBuilder();
      for (GrantedAuthority authority : authorities) {
        authorityListAsString.append(String.format("\n\t%s", authority));
      }
      logger.debug("Returning user {} with authorities:{}", username, authorityListAsString);
    }

    // Update the user in the security service if it matches the user whose authorities are being returned

    return authorities;
  }

  /**
   * Return the attributes names this object will search for
   *
   * @return a {@link Collection} containing such attribute names
   */
  public Collection<String> getAttributeNames() {
    return new HashSet<>(attributeNames);
  }

  /**
   * Get the role prefix being used by this object. Please note that such prefix can be empty.
   *
   * @return the role prefix in use.
   */
  public String getRolePrefix() {
    return prefix;
  }

  /**
   * Get the exclude prefixes being used by this object.
   *
   * @return the role prefix in use.
   */
  public String[] getExcludePrefixes() {
    return excludedPrefixes.toArray(new String[0]);
  }

  /**
   * Get the property that defines whether or not the role names should be converted to uppercase.
   *
   * @return {@code true} if this class converts the role names to uppercase. {@code false} otherwise.
   */
  public boolean getConvertToUpperCase() {
    return uppercase;
  }

  /**
   * Get the extra roles to be added to any user returned by this authorities populator
   *
   * @return A {@link Collection} of {@link String}s representing the additional roles
   */
  public String[] getAdditionalAuthorities() {
    return additionalAuthorities.clone();
  }

  /**
   * Cleans the spaces and unnecessary underscores out of the provided Role and converts it to uppercase if needed
   *
   * @param rawRole
   *          the raw Role, which should be cleaned and converted
   * @param toUpperCase
   *          set if the Role should be converted to uppercase
   */
  private String roleCleanUpperCase(String rawRole, boolean toUpperCase) {
    if (toUpperCase) {
      return StringUtils.trimToEmpty(rawRole).replaceAll(ROLE_CLEAN_REGEXP, ROLE_CLEAN_REPLACEMENT)
              .toUpperCase();
    }
    else {
      return StringUtils.trimToEmpty(rawRole).replaceAll(ROLE_CLEAN_REGEXP, ROLE_CLEAN_REPLACEMENT);
    }
  }

  /**
   * Add the specified authorities to the provided set
   *
   * @param authorities
   *          a set containing the authorities
   * @param values
   *          the values to add to the set
   * @param addAsGroup
   *          if enabled, roles and groups are added to the authorities
   * @param addPrefix
   *          if enabled, the set prefix is added to the authority, if no excludePrefix applies
   */
  private void addAuthorities(Set<GrantedAuthority> authorities, final String[] values,
                  final boolean addAsGroup, final boolean addPrefix) {

    if (values != null) {
      Organization org = securityService.getOrganization();
      if (!organization.equals(org)) {
        throw new SecurityException(String.format(
            "Current request belongs to the organization \"%s\". Expected \"%s\"",
            org.getId(), organization.getId()));
      }

      for (String value : values) {
        /*
         * Please note the prefix logic for roles:
         *
         * - Roles that start with any of the "exclude prefixes" are left intact
         * - In any other case, the "role prefix" is prepended to the roles read from LDAP
         *
         * This only applies to the prefix addition. The conversion to uppercase is independent from these
         * considerations
         */
        String authority = roleCleanUpperCase(value, uppercase);

        // Ignore the empty parts
        if (!authority.isEmpty()) {
          // Check if this role is a group role and assign the groups appropriately
          List<Role> groupRoles;
          if (groupRoleProvider != null && addAsGroup) {
            groupRoles = groupRoleProvider.getRolesForGroup(authority);
          } else {
            groupRoles = Collections.emptyList();
          }

          // Try to add the prefix if appropriate
          String prefix = this.prefix;

          if (addPrefix) {
            if (!prefix.isEmpty()) {
              boolean hasExcludePrefix = false;
              for (String excludePrefix : excludedPrefixes) {
                if (authority.startsWith(excludePrefix)) {
                  hasExcludePrefix = true;
                  break;
                }
              }
              if (hasExcludePrefix) {
                prefix = "";
              }
            }
          }
          else {
            prefix = "";
          }

          authority = (prefix + authority).replaceAll(ROLE_CLEAN_REGEXP, ROLE_CLEAN_REPLACEMENT);

          logger.debug("Parsed LDAP role \"{}\" to role \"{}\"", value, authority);

          if (!groupRoles.isEmpty()) {
            // The authority is a group role
            logger.debug("Found group for the group with group role \"{}\"", authority);
            for (Role role : groupRoles) {
              authorities.add(new SimpleGrantedAuthority(role.getName()));
              logger.debug("\tAdded role from role \"{}\"'s group: {}", authority, role);
            }
          }

          // Finally, add the authority itself
          authorities.add(new SimpleGrantedAuthority(authority));

        } else {
          logger.debug("Found empty authority. Ignoring...");
        }
      }
    }
  }

  /** OSGi callback for setting the role group service. */
  public void setOrgDirectory(JpaGroupRoleProvider groupRoleProvider) {
    this.groupRoleProvider = groupRoleProvider;
  }

  /** OSGi callback for setting the security service. */
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

}