MoodleUserProviderInstance.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.moodle;

import org.opencastproject.security.api.CachingUserProviderMXBean;
import org.opencastproject.security.api.Group;
import org.opencastproject.security.api.JaxbOrganization;
import org.opencastproject.security.api.JaxbRole;
import org.opencastproject.security.api.JaxbUser;
import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.Role;
import org.opencastproject.security.api.RoleProvider;
import org.opencastproject.security.api.SecurityConstants;
import org.opencastproject.security.api.User;
import org.opencastproject.security.api.UserProvider;
import org.opencastproject.userdirectory.moodle.MoodleWebService.CoreUserGetUserByFieldFilters;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ExecutionError;
import com.google.common.util.concurrent.UncheckedExecutionException;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.PatternSyntaxException;

import javax.management.InstanceNotFoundException;
import javax.management.MBeanServer;
import javax.management.ObjectName;

/**
 * A UserProvider that reads user roles from Moodle.
 */
public class MoodleUserProviderInstance implements UserProvider, RoleProvider, CachingUserProviderMXBean {
  /**
   * User and role provider name.
   */
  private static final String PROVIDER_NAME = "moodle";

  /**
   * The logger.
   */
  private static final Logger logger = LoggerFactory.getLogger(MoodleUserProviderInstance.class);

  /**
   * Suffix for Moodle roles with the learner capability.
   */
  private static final String LEARNER_ROLE_SUFFIX = "Learner";

  /**
   * Suffix for Moodle roles with the instructor capability.
   */
  private static final String INSTRUCTOR_ROLE_SUFFIX = "Instructor";

  /**
   * Prefix for Moodle group roles.
   */
  private static final String GROUP_ROLE_PREFIX = "G";

  /**
   * Suffix for Moodle group roles.
   */
  private static final String GROUP_ROLE_SUFFIX = "Learner";

  /**
   * The Moodle web service client.
   */
  private MoodleWebService client;

  /**
   * The organization.
   */
  private Organization organization;

  /**
   * Whether to create group roles.
   */
  private boolean groupRoles;

  /**
   * Regular expression for matching valid courses.
   */
  private String coursePattern;

  /**
   * Regular expression for matching valid users.
   */
  private String userPattern;

  /**
   * Regular expression for matching valid groups.
   */
  private String groupPattern;

  /**
   * String to prepend to context roles like “1234_Learner”
   */
  private final String contextRolePrefix;

  /**
   * A cache of users, which lightens the load on Moodle.
   */
  private LoadingCache<String, Object> cache;

  /**
   * A token to store in the miss cache.
   */
  private Object nullToken = new Object();

  /**
   * The total number of requests made to load users.
   */
  private AtomicLong loadUserRequests;

  /**
   * The number of requests made to Moodle.
   */
  private AtomicLong moodleWebServiceRequests;

  /** If usernames requested from Moodle shall be converted to lowercase */
  private final boolean lowercaseUsername;

  private final List<String> ignoredUsernames;

  /**
   * Constructs an Moodle user provider with the needed settings.
   *
   * @param pid             The pid of this service.
   * @param client          The Moodle web service client.
   * @param organization    The organization.
   * @param coursePattern   The pattern of a Moodle course ID.
   * @param userPattern     The pattern of a Moodle user ID.
   * @param groupPattern    The pattern of a Moodle group ID.
   * @param groupRoles      Whether to activate groupRoles
   * @param cacheSize       The number of users to cache.
   * @param cacheExpiration The number of minutes to cache users.
   * @param adminUserName   Name of the global admin user.
   * @param contextRolePrefix Prefix to prepend to context roles like 1234_Learner
   */
  public MoodleUserProviderInstance(String pid, MoodleWebService client, Organization organization,
          String coursePattern, String userPattern, String groupPattern, boolean groupRoles, int cacheSize,
          int cacheExpiration, String adminUserName, final boolean lowercaseUsername, final String contextRolePrefix) {
    this.client = client;
    this.organization = organization;
    this.groupRoles = groupRoles;
    this.coursePattern = coursePattern;
    this.userPattern = userPattern;
    this.groupPattern = groupPattern;
    this.lowercaseUsername = lowercaseUsername;
    this.contextRolePrefix = contextRolePrefix;

    // initialize user filter
    this.ignoredUsernames = new ArrayList<>();
    this.ignoredUsernames.add("");
    this.ignoredUsernames.add(SecurityConstants.GLOBAL_ANONYMOUS_USERNAME);
    if (StringUtils.isNoneEmpty(adminUserName)) {
      ignoredUsernames.add(adminUserName);
    }

    logger.info("Creating new MoodleUserProviderInstance(pid={}, url={}, cacheSize={}, cacheExpiration={})", pid,
            client.getURL(), cacheSize, cacheExpiration);

    // Setup the caches
    cache = CacheBuilder.newBuilder().maximumSize(cacheSize).expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
            .build(new CacheLoader<String, Object>() {
              @Override
              public Object load(String username) {
                User user = loadUserFromMoodle(username);
                return user == null ? nullToken : user;
              }
            });

    registerMBean(pid);
  }

  ////////////////////////////
  // CachingUserProviderMXBean

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.CachingUserProviderMXBean#getCacheHitRatio()
   */
  @Override
  public float getCacheHitRatio() {
    if (loadUserRequests.get() == 0) {
      return 0;
    }
    return (float) (loadUserRequests.get() - moodleWebServiceRequests.get()) / loadUserRequests.get();
  }

  /**
   * Registers an MXBean.
   */
  private void registerMBean(String pid) {
    // register with jmx
    loadUserRequests = new AtomicLong();
    moodleWebServiceRequests = new AtomicLong();
    try {
      ObjectName name;
      name = MoodleUserProviderFactory.getObjectName(pid);
      Object mbean = this;
      MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
      try {
        mbs.unregisterMBean(name);
      } catch (InstanceNotFoundException e) {
        logger.debug("{} was not registered", name);
      }
      mbs.registerMBean(mbean, name);
    } catch (Exception e) {
      logger.error("Unable to register {} as an mbean", this, e);
    }
  }

  ///////////////////////
  // UserProvider methods

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#getName()
   */
  @Override
  public String getName() {
    return PROVIDER_NAME;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#getUsers()
   */
  @Override
  public Iterator<User> getUsers() {
    // We never enumerate all users
    return Collections.emptyIterator();
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#loadUser(java.lang.String)
   */
  @Override
  public User loadUser(String userName) {
    loadUserRequests.incrementAndGet();
    try {
      Object user = cache.getUnchecked(userName);
      if (user == nullToken) {
        logger.debug("Returning null user from cache");
        return null;
      } else {
        logger.debug("Returning user {} from cache", userName);
        return (User) user;
      }
    } catch (ExecutionError e) {
      logger.warn("Exception while loading user {}", userName, e);
      return null;
    } catch (UncheckedExecutionException e) {
      logger.warn("Exception while loading user {}", userName, e);
      return null;
    }
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#countUsers()
   */
  @Override
  public long countUsers() {
    // Not meaningful, as we never enumerate users
    return 0;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#getOrganization()
   */
  @Override
  public String getOrganization() {
    return organization.getId();
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#findUsers(java.lang.String, int, int)
   */
  @Override
  public Iterator<User> findUsers(String query, int offset, int limit) {
    if (query == null) {
      throw new IllegalArgumentException("Query must be set");
    }

    if (query.endsWith("%")) {
      query = query.substring(0, query.length() - 1);
    }

    if (query.isEmpty()) {
      return Collections.emptyIterator();
    }

    // Check if user matches pattern
    try {
      if ((userPattern != null) && !query.matches(userPattern)) {
        logger.debug("verify user {} failed regexp {}", query, userPattern);
        return Collections.emptyIterator();
      }
    } catch (PatternSyntaxException e) {
      logger.warn("Invalid regular expression for user pattern {} - disabling checks", userPattern);
      userPattern = null;
    }

    // Load User
    List<User> users = new LinkedList<>();

    User user = loadUser(query);
    if (user != null) {
      users.add(user);
    }

    return users.iterator();
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#invalidate(java.lang.String)
   */
  @Override
  public void invalidate(String userName) {
    cache.invalidate(userName);
  }

  ///////////////////////
  // RoleProvider methods

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.RoleProvider#getRolesForUser(java.lang.String)
   */
  @Override
  public List<Role> getRolesForUser(String username) {
    List<Role> roles = new LinkedList<>();

    // Don't answer for admin, anonymous or empty user
    if (ignoredUsernames.stream().anyMatch(u -> u.equals(username))) {
      logger.debug("we don't answer for: {}", username);
      return roles;
    }

    User user = loadUser(username);
    if (user != null) {
      logger.debug("Returning cached role set for {}", username);
      return new ArrayList<>(user.getRoles());
    }

    // Not found
    logger.debug("Return empty role set for {} - not found in Moodle", username);
    return new LinkedList<>();
  }

  /**
   * {@inheritDoc}
   * <p>
   * We search for COURSEID, COURSEID_Learner, COURSEID_Instructor
   */
  @Override
  public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
    // Don't return roles for users or groups
    if (target == Role.Target.USER) {
      return Collections.emptyIterator();
    }

    boolean exact = true;
    boolean ltirole = false;

    if (query.endsWith("%")) {
      exact = false;
      query = query.substring(0, query.length() - 1);
    }

    if (query.isEmpty()) {
      return Collections.emptyIterator();
    }

    // Verify query starts with prefix configured for this user provider instance
    if (!query.startsWith(contextRolePrefix)) {
      return Collections.emptyIterator();
    }

    // Verify that role name ends with LEARNER_ROLE_SUFFIX or INSTRUCTOR_ROLE_SUFFIX
    if (exact
        && !query.endsWith("_" + LEARNER_ROLE_SUFFIX)
        && !query.endsWith("_" + INSTRUCTOR_ROLE_SUFFIX)
        && !query.endsWith("_" + GROUP_ROLE_SUFFIX)) {
      return Collections.emptyIterator();
    }

    final String groupRolePrefix = contextRolePrefix + GROUP_ROLE_PREFIX;
    final boolean findGroupRole = groupRoles && query.startsWith(groupRolePrefix);

    // Extract Moodle id
    String moodleId = findGroupRole ? query.substring(groupRolePrefix.length()) : query;
    if (query.endsWith("_" + LEARNER_ROLE_SUFFIX)) {
      moodleId = query.substring(contextRolePrefix.length(), query.lastIndexOf("_" + LEARNER_ROLE_SUFFIX));
      ltirole = true;
    } else if (query.endsWith("_" + INSTRUCTOR_ROLE_SUFFIX)) {
      moodleId = query.substring(contextRolePrefix.length(), query.lastIndexOf("_" + INSTRUCTOR_ROLE_SUFFIX));
      ltirole = true;
    } else if (query.endsWith("_" + GROUP_ROLE_SUFFIX)) {
      moodleId = query.substring(contextRolePrefix.length(), query.lastIndexOf("_" + GROUP_ROLE_SUFFIX));
      ltirole = true;
    }

    // Check if matches patterns
    String pattern = findGroupRole ? groupPattern : coursePattern;
    try {
      if ((pattern != null) && !moodleId.matches(pattern)) {
        logger.debug("Verify Moodle ID {} failed regexp {}", moodleId, pattern);
        return Collections.emptyIterator();
      }
    } catch (PatternSyntaxException e) {
      logger.warn("Invalid regular expression for pattern {} - disabling checks", pattern);
      if (findGroupRole) {
        groupPattern = null;
      } else {
        coursePattern = null;
      }
    }

    // Roles list
    List<Role> roles = new LinkedList<>();
    JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
    if (ltirole) {
      // Query is for a Moodle ID and an LTI role (Instructor/Learner/Group)
      roles.add(new JaxbRole(query, jaxbOrganization, "Moodle Site Role", Role.Type.EXTERNAL));
    } else if (findGroupRole) {
      // Group ID
      roles.add(new JaxbRole(contextRolePrefix + GROUP_ROLE_PREFIX + moodleId + "_" + GROUP_ROLE_SUFFIX,
          jaxbOrganization, "Moodle Group Learner Role", Role.Type.EXTERNAL));
    } else {
      // Course ID - return both roles
      roles.add(new JaxbRole(moodleId + "_" + INSTRUCTOR_ROLE_SUFFIX, jaxbOrganization,
              "Moodle Course Instructor Role", Role.Type.EXTERNAL));
      roles.add(new JaxbRole(moodleId + "_" + LEARNER_ROLE_SUFFIX, jaxbOrganization, "Moodle Course Learner Role",
              Role.Type.EXTERNAL));
    }

    return roles.iterator();
  }

  /////////////////
  // Helper methods

  /**
   * Loads a user from Moodle.
   *
   * @param username The username.
   * @return The user.
   */
  private User loadUserFromMoodle(String username) {
    if (lowercaseUsername) {
      username = username.toLowerCase();
    }

    logger.debug("loadUserFromMoodle({})", username);

    if (cache == null) {
      throw new IllegalStateException("The Moodle user detail service has not yet been configured");
    }

    // Don't answer for admin, anonymous or empty user
    if (ignoredUsernames.contains(username)) {
      logger.debug("We don't answer for: " + username);
      return null;
    }

    JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);

    // update cache statistics
    moodleWebServiceRequests.incrementAndGet();

    Thread currentThread = Thread.currentThread();
    ClassLoader originalClassloader = currentThread.getContextClassLoader();

    try {
      // Load user
      List<MoodleUser> moodleUsers = client
              .coreUserGetUsersByField(CoreUserGetUserByFieldFilters.username, Collections.singletonList(username));

      if (moodleUsers.isEmpty()) {
        logger.debug("User {} not found in Moodle system", username);
        return null;
      }
      MoodleUser moodleUser = moodleUsers.get(0);

      // Load Roles
      List<String> courseIdsInstructor = client.toolOpencastGetCoursesForInstructor(username);
      List<String> courseIdsLearner = client.toolOpencastGetCoursesForLearner(username);
      List<String> groupIdsLearner = groupRoles
          ? client.toolOpencastGetGroupsForLearner(username)
          : Collections.emptyList();

      // Create Opencast Objects
      Set<JaxbRole> roles = new HashSet<>();
      roles.add(new JaxbRole(Group.ROLE_PREFIX + contextRolePrefix + "MOODLE", jaxbOrganization, "Moodle Users",
          Role.Type.EXTERNAL_GROUP));
      for (final String courseId : courseIdsInstructor) {
        roles.add(contextRole(courseId, INSTRUCTOR_ROLE_SUFFIX, jaxbOrganization));
      }
      for (final String courseId : courseIdsLearner) {
        roles.add(contextRole(courseId, LEARNER_ROLE_SUFFIX, jaxbOrganization));
      }
      for (final String groupId : groupIdsLearner) {
        roles.add(contextRole(GROUP_ROLE_PREFIX + groupId, GROUP_ROLE_SUFFIX, jaxbOrganization));
      }

      return new JaxbUser(moodleUser.getUsername(), null, moodleUser.getFullname(), moodleUser.getEmail(),
              this.getName(), jaxbOrganization, roles);
    } catch (Exception e) {
      logger.warn("Exception loading Moodle user {} at {}", username, client.getURL());
    } finally {
      currentThread.setContextClassLoader(originalClassloader);
    }

    return null;
  }

  /**
   * Create an Opencast JaxbRole based on a Moodle user's context.
   *
   * @param context Moodle user's context like course identifier
   * @param contextRole Moodle user's context role like Instructor
   * @param organization Opencast organization to create user for
   * @return JaxbRole
   */
  private JaxbRole contextRole(final String context, final String contextRole, final JaxbOrganization organization) {
    final String name = contextRolePrefix + context + "_" + contextRole;
    final String description = "Moodle Course " + contextRole + " Role";
    return new JaxbRole(name, organization, description, Role.Type.EXTERNAL);
  }
}