View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.userdirectory.moodle;
23  
24  import org.opencastproject.security.api.CachingUserProviderMXBean;
25  import org.opencastproject.security.api.Group;
26  import org.opencastproject.security.api.JaxbOrganization;
27  import org.opencastproject.security.api.JaxbRole;
28  import org.opencastproject.security.api.JaxbUser;
29  import org.opencastproject.security.api.Organization;
30  import org.opencastproject.security.api.Role;
31  import org.opencastproject.security.api.RoleProvider;
32  import org.opencastproject.security.api.SecurityConstants;
33  import org.opencastproject.security.api.User;
34  import org.opencastproject.security.api.UserProvider;
35  import org.opencastproject.userdirectory.moodle.MoodleWebService.CoreUserGetUserByFieldFilters;
36  
37  import com.google.common.cache.CacheBuilder;
38  import com.google.common.cache.CacheLoader;
39  import com.google.common.cache.LoadingCache;
40  import com.google.common.util.concurrent.ExecutionError;
41  import com.google.common.util.concurrent.UncheckedExecutionException;
42  
43  import org.apache.commons.lang3.StringUtils;
44  import org.slf4j.Logger;
45  import org.slf4j.LoggerFactory;
46  
47  import java.lang.management.ManagementFactory;
48  import java.util.ArrayList;
49  import java.util.Collections;
50  import java.util.HashSet;
51  import java.util.Iterator;
52  import java.util.LinkedList;
53  import java.util.List;
54  import java.util.Set;
55  import java.util.concurrent.TimeUnit;
56  import java.util.concurrent.atomic.AtomicLong;
57  import java.util.regex.PatternSyntaxException;
58  
59  import javax.management.InstanceNotFoundException;
60  import javax.management.MBeanServer;
61  import javax.management.ObjectName;
62  
63  /**
64   * A UserProvider that reads user roles from Moodle.
65   */
66  public class MoodleUserProviderInstance implements UserProvider, RoleProvider, CachingUserProviderMXBean {
67    /**
68     * User and role provider name.
69     */
70    private static final String PROVIDER_NAME = "moodle";
71  
72    /**
73     * The logger.
74     */
75    private static final Logger logger = LoggerFactory.getLogger(MoodleUserProviderInstance.class);
76  
77    /**
78     * Suffix for Moodle roles with the learner capability.
79     */
80    private static final String LEARNER_ROLE_SUFFIX = "Learner";
81  
82    /**
83     * Suffix for Moodle roles with the instructor capability.
84     */
85    private static final String INSTRUCTOR_ROLE_SUFFIX = "Instructor";
86  
87    /**
88     * Prefix for Moodle group roles.
89     */
90    private static final String GROUP_ROLE_PREFIX = "G";
91  
92    /**
93     * Suffix for Moodle group roles.
94     */
95    private static final String GROUP_ROLE_SUFFIX = "Learner";
96  
97    /**
98     * The Moodle web service client.
99     */
100   private MoodleWebService client;
101 
102   /**
103    * The organization.
104    */
105   private Organization organization;
106 
107   /**
108    * Whether to create group roles.
109    */
110   private boolean groupRoles;
111 
112   /**
113    * Regular expression for matching valid courses.
114    */
115   private String coursePattern;
116 
117   /**
118    * Regular expression for matching valid users.
119    */
120   private String userPattern;
121 
122   /**
123    * Regular expression for matching valid groups.
124    */
125   private String groupPattern;
126 
127   /**
128    * String to prepend to context roles like “1234_Learner”
129    */
130   private final String contextRolePrefix;
131 
132   /**
133    * A cache of users, which lightens the load on Moodle.
134    */
135   private LoadingCache<String, Object> cache;
136 
137   /**
138    * A token to store in the miss cache.
139    */
140   private Object nullToken = new Object();
141 
142   /**
143    * The total number of requests made to load users.
144    */
145   private AtomicLong loadUserRequests;
146 
147   /**
148    * The number of requests made to Moodle.
149    */
150   private AtomicLong moodleWebServiceRequests;
151 
152   /** If usernames requested from Moodle shall be converted to lowercase */
153   private final boolean lowercaseUsername;
154 
155   private final List<String> ignoredUsernames;
156 
157   /**
158    * Constructs an Moodle user provider with the needed settings.
159    *
160    * @param pid             The pid of this service.
161    * @param client          The Moodle web service client.
162    * @param organization    The organization.
163    * @param coursePattern   The pattern of a Moodle course ID.
164    * @param userPattern     The pattern of a Moodle user ID.
165    * @param groupPattern    The pattern of a Moodle group ID.
166    * @param groupRoles      Whether to activate groupRoles
167    * @param cacheSize       The number of users to cache.
168    * @param cacheExpiration The number of minutes to cache users.
169    * @param adminUserName   Name of the global admin user.
170    * @param contextRolePrefix Prefix to prepend to context roles like 1234_Learner
171    */
172   public MoodleUserProviderInstance(String pid, MoodleWebService client, Organization organization,
173           String coursePattern, String userPattern, String groupPattern, boolean groupRoles, int cacheSize,
174           int cacheExpiration, String adminUserName, final boolean lowercaseUsername, final String contextRolePrefix) {
175     this.client = client;
176     this.organization = organization;
177     this.groupRoles = groupRoles;
178     this.coursePattern = coursePattern;
179     this.userPattern = userPattern;
180     this.groupPattern = groupPattern;
181     this.lowercaseUsername = lowercaseUsername;
182     this.contextRolePrefix = contextRolePrefix;
183 
184     // initialize user filter
185     this.ignoredUsernames = new ArrayList<>();
186     this.ignoredUsernames.add("");
187     this.ignoredUsernames.add(SecurityConstants.GLOBAL_ANONYMOUS_USERNAME);
188     if (StringUtils.isNoneEmpty(adminUserName)) {
189       ignoredUsernames.add(adminUserName);
190     }
191 
192     logger.info("Creating new MoodleUserProviderInstance(pid={}, url={}, cacheSize={}, cacheExpiration={})", pid,
193             client.getURL(), cacheSize, cacheExpiration);
194 
195     // Setup the caches
196     cache = CacheBuilder.newBuilder().maximumSize(cacheSize).expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
197             .build(new CacheLoader<String, Object>() {
198               @Override
199               public Object load(String username) {
200                 User user = loadUserFromMoodle(username);
201                 return user == null ? nullToken : user;
202               }
203             });
204 
205     registerMBean(pid);
206   }
207 
208   ////////////////////////////
209   // CachingUserProviderMXBean
210 
211   /**
212    * {@inheritDoc}
213    *
214    * @see org.opencastproject.security.api.CachingUserProviderMXBean#getCacheHitRatio()
215    */
216   @Override
217   public float getCacheHitRatio() {
218     if (loadUserRequests.get() == 0) {
219       return 0;
220     }
221     return (float) (loadUserRequests.get() - moodleWebServiceRequests.get()) / loadUserRequests.get();
222   }
223 
224   /**
225    * Registers an MXBean.
226    */
227   private void registerMBean(String pid) {
228     // register with jmx
229     loadUserRequests = new AtomicLong();
230     moodleWebServiceRequests = new AtomicLong();
231     try {
232       ObjectName name;
233       name = MoodleUserProviderFactory.getObjectName(pid);
234       Object mbean = this;
235       MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
236       try {
237         mbs.unregisterMBean(name);
238       } catch (InstanceNotFoundException e) {
239         logger.debug("{} was not registered", name);
240       }
241       mbs.registerMBean(mbean, name);
242     } catch (Exception e) {
243       logger.error("Unable to register {} as an mbean", this, e);
244     }
245   }
246 
247   ///////////////////////
248   // UserProvider methods
249 
250   /**
251    * {@inheritDoc}
252    *
253    * @see org.opencastproject.security.api.UserProvider#getName()
254    */
255   @Override
256   public String getName() {
257     return PROVIDER_NAME;
258   }
259 
260   /**
261    * {@inheritDoc}
262    *
263    * @see org.opencastproject.security.api.UserProvider#getUsers()
264    */
265   @Override
266   public Iterator<User> getUsers() {
267     // We never enumerate all users
268     return Collections.emptyIterator();
269   }
270 
271   /**
272    * {@inheritDoc}
273    *
274    * @see org.opencastproject.security.api.UserProvider#loadUser(java.lang.String)
275    */
276   @Override
277   public User loadUser(String userName) {
278     loadUserRequests.incrementAndGet();
279     try {
280       Object user = cache.getUnchecked(userName);
281       if (user == nullToken) {
282         logger.debug("Returning null user from cache");
283         return null;
284       } else {
285         logger.debug("Returning user {} from cache", userName);
286         return (User) user;
287       }
288     } catch (ExecutionError e) {
289       logger.warn("Exception while loading user {}", userName, e);
290       return null;
291     } catch (UncheckedExecutionException e) {
292       logger.warn("Exception while loading user {}", userName, e);
293       return null;
294     }
295   }
296 
297   /**
298    * {@inheritDoc}
299    *
300    * @see org.opencastproject.security.api.UserProvider#countUsers()
301    */
302   @Override
303   public long countUsers() {
304     // Not meaningful, as we never enumerate users
305     return 0;
306   }
307 
308   /**
309    * {@inheritDoc}
310    *
311    * @see org.opencastproject.security.api.UserProvider#getOrganization()
312    */
313   @Override
314   public String getOrganization() {
315     return organization.getId();
316   }
317 
318   /**
319    * {@inheritDoc}
320    *
321    * @see org.opencastproject.security.api.UserProvider#findUsers(java.lang.String, int, int)
322    */
323   @Override
324   public Iterator<User> findUsers(String query, int offset, int limit) {
325     if (query == null) {
326       throw new IllegalArgumentException("Query must be set");
327     }
328 
329     if (query.endsWith("%")) {
330       query = query.substring(0, query.length() - 1);
331     }
332 
333     if (query.isEmpty()) {
334       return Collections.emptyIterator();
335     }
336 
337     // Check if user matches pattern
338     try {
339       if ((userPattern != null) && !query.matches(userPattern)) {
340         logger.debug("verify user {} failed regexp {}", query, userPattern);
341         return Collections.emptyIterator();
342       }
343     } catch (PatternSyntaxException e) {
344       logger.warn("Invalid regular expression for user pattern {} - disabling checks", userPattern);
345       userPattern = null;
346     }
347 
348     // Load User
349     List<User> users = new LinkedList<>();
350 
351     User user = loadUser(query);
352     if (user != null) {
353       users.add(user);
354     }
355 
356     return users.iterator();
357   }
358 
359   /**
360    * {@inheritDoc}
361    *
362    * @see org.opencastproject.security.api.UserProvider#invalidate(java.lang.String)
363    */
364   @Override
365   public void invalidate(String userName) {
366     cache.invalidate(userName);
367   }
368 
369   ///////////////////////
370   // RoleProvider methods
371 
372   /**
373    * {@inheritDoc}
374    *
375    * @see org.opencastproject.security.api.RoleProvider#getRolesForUser(java.lang.String)
376    */
377   @Override
378   public List<Role> getRolesForUser(String username) {
379     List<Role> roles = new LinkedList<>();
380 
381     // Don't answer for admin, anonymous or empty user
382     if (ignoredUsernames.stream().anyMatch(u -> u.equals(username))) {
383       logger.debug("we don't answer for: {}", username);
384       return roles;
385     }
386 
387     User user = loadUser(username);
388     if (user != null) {
389       logger.debug("Returning cached role set for {}", username);
390       return new ArrayList<>(user.getRoles());
391     }
392 
393     // Not found
394     logger.debug("Return empty role set for {} - not found in Moodle", username);
395     return new LinkedList<>();
396   }
397 
398   /**
399    * {@inheritDoc}
400    * <p>
401    * We search for COURSEID, COURSEID_Learner, COURSEID_Instructor
402    */
403   @Override
404   public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
405     // Don't return roles for users or groups
406     if (target == Role.Target.USER) {
407       return Collections.emptyIterator();
408     }
409 
410     boolean exact = true;
411     boolean ltirole = false;
412 
413     if (query.endsWith("%")) {
414       exact = false;
415       query = query.substring(0, query.length() - 1);
416     }
417 
418     if (query.isEmpty()) {
419       return Collections.emptyIterator();
420     }
421 
422     // Verify query starts with prefix configured for this user provider instance
423     if (!query.startsWith(contextRolePrefix)) {
424       return Collections.emptyIterator();
425     }
426 
427     // Verify that role name ends with LEARNER_ROLE_SUFFIX or INSTRUCTOR_ROLE_SUFFIX
428     if (exact
429         && !query.endsWith("_" + LEARNER_ROLE_SUFFIX)
430         && !query.endsWith("_" + INSTRUCTOR_ROLE_SUFFIX)
431         && !query.endsWith("_" + GROUP_ROLE_SUFFIX)) {
432       return Collections.emptyIterator();
433     }
434 
435     final String groupRolePrefix = contextRolePrefix + GROUP_ROLE_PREFIX;
436     final boolean findGroupRole = groupRoles && query.startsWith(groupRolePrefix);
437 
438     // Extract Moodle id
439     String moodleId = findGroupRole ? query.substring(groupRolePrefix.length()) : query;
440     if (query.endsWith("_" + LEARNER_ROLE_SUFFIX)) {
441       moodleId = query.substring(contextRolePrefix.length(), query.lastIndexOf("_" + LEARNER_ROLE_SUFFIX));
442       ltirole = true;
443     } else if (query.endsWith("_" + INSTRUCTOR_ROLE_SUFFIX)) {
444       moodleId = query.substring(contextRolePrefix.length(), query.lastIndexOf("_" + INSTRUCTOR_ROLE_SUFFIX));
445       ltirole = true;
446     } else if (query.endsWith("_" + GROUP_ROLE_SUFFIX)) {
447       moodleId = query.substring(contextRolePrefix.length(), query.lastIndexOf("_" + GROUP_ROLE_SUFFIX));
448       ltirole = true;
449     }
450 
451     // Check if matches patterns
452     String pattern = findGroupRole ? groupPattern : coursePattern;
453     try {
454       if ((pattern != null) && !moodleId.matches(pattern)) {
455         logger.debug("Verify Moodle ID {} failed regexp {}", moodleId, pattern);
456         return Collections.emptyIterator();
457       }
458     } catch (PatternSyntaxException e) {
459       logger.warn("Invalid regular expression for pattern {} - disabling checks", pattern);
460       if (findGroupRole) {
461         groupPattern = null;
462       } else {
463         coursePattern = null;
464       }
465     }
466 
467     // Roles list
468     List<Role> roles = new LinkedList<>();
469     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
470     if (ltirole) {
471       // Query is for a Moodle ID and an LTI role (Instructor/Learner/Group)
472       roles.add(new JaxbRole(query, jaxbOrganization, "Moodle Site Role", Role.Type.EXTERNAL));
473     } else if (findGroupRole) {
474       // Group ID
475       roles.add(new JaxbRole(contextRolePrefix + GROUP_ROLE_PREFIX + moodleId + "_" + GROUP_ROLE_SUFFIX,
476           jaxbOrganization, "Moodle Group Learner Role", Role.Type.EXTERNAL));
477     } else {
478       // Course ID - return both roles
479       roles.add(new JaxbRole(moodleId + "_" + INSTRUCTOR_ROLE_SUFFIX, jaxbOrganization,
480               "Moodle Course Instructor Role", Role.Type.EXTERNAL));
481       roles.add(new JaxbRole(moodleId + "_" + LEARNER_ROLE_SUFFIX, jaxbOrganization, "Moodle Course Learner Role",
482               Role.Type.EXTERNAL));
483     }
484 
485     return roles.iterator();
486   }
487 
488   /////////////////
489   // Helper methods
490 
491   /**
492    * Loads a user from Moodle.
493    *
494    * @param username The username.
495    * @return The user.
496    */
497   private User loadUserFromMoodle(String username) {
498     if (lowercaseUsername) {
499       username = username.toLowerCase();
500     }
501 
502     logger.debug("loadUserFromMoodle({})", username);
503 
504     if (cache == null) {
505       throw new IllegalStateException("The Moodle user detail service has not yet been configured");
506     }
507 
508     // Don't answer for admin, anonymous or empty user
509     if (ignoredUsernames.contains(username)) {
510       logger.debug("We don't answer for: " + username);
511       return null;
512     }
513 
514     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
515 
516     // update cache statistics
517     moodleWebServiceRequests.incrementAndGet();
518 
519     Thread currentThread = Thread.currentThread();
520     ClassLoader originalClassloader = currentThread.getContextClassLoader();
521 
522     try {
523       // Load user
524       List<MoodleUser> moodleUsers = client
525               .coreUserGetUsersByField(CoreUserGetUserByFieldFilters.username, Collections.singletonList(username));
526 
527       if (moodleUsers.isEmpty()) {
528         logger.debug("User {} not found in Moodle system", username);
529         return null;
530       }
531       MoodleUser moodleUser = moodleUsers.get(0);
532 
533       // Load Roles
534       List<String> courseIdsInstructor = client.toolOpencastGetCoursesForInstructor(username);
535       List<String> courseIdsLearner = client.toolOpencastGetCoursesForLearner(username);
536       List<String> groupIdsLearner = groupRoles
537           ? client.toolOpencastGetGroupsForLearner(username)
538           : Collections.emptyList();
539 
540       // Create Opencast Objects
541       Set<JaxbRole> roles = new HashSet<>();
542       roles.add(new JaxbRole(Group.ROLE_PREFIX + contextRolePrefix + "MOODLE", jaxbOrganization, "Moodle Users",
543           Role.Type.EXTERNAL_GROUP));
544       for (final String courseId : courseIdsInstructor) {
545         roles.add(contextRole(courseId, INSTRUCTOR_ROLE_SUFFIX, jaxbOrganization));
546       }
547       for (final String courseId : courseIdsLearner) {
548         roles.add(contextRole(courseId, LEARNER_ROLE_SUFFIX, jaxbOrganization));
549       }
550       for (final String groupId : groupIdsLearner) {
551         roles.add(contextRole(GROUP_ROLE_PREFIX + groupId, GROUP_ROLE_SUFFIX, jaxbOrganization));
552       }
553 
554       return new JaxbUser(moodleUser.getUsername(), null, moodleUser.getFullname(), moodleUser.getEmail(),
555               this.getName(), jaxbOrganization, roles);
556     } catch (Exception e) {
557       logger.warn("Exception loading Moodle user {} at {}", username, client.getURL());
558     } finally {
559       currentThread.setContextClassLoader(originalClassloader);
560     }
561 
562     return null;
563   }
564 
565   /**
566    * Create an Opencast JaxbRole based on a Moodle user's context.
567    *
568    * @param context Moodle user's context like course identifier
569    * @param contextRole Moodle user's context role like Instructor
570    * @param organization Opencast organization to create user for
571    * @return JaxbRole
572    */
573   private JaxbRole contextRole(final String context, final String contextRole, final JaxbOrganization organization) {
574     final String name = contextRolePrefix + context + "_" + contextRole;
575     final String description = "Moodle Course " + contextRole + " Role";
576     return new JaxbRole(name, organization, description, Role.Type.EXTERNAL);
577   }
578 }