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.canvas;
23  
24  import org.opencastproject.security.api.Group;
25  import org.opencastproject.security.api.JaxbOrganization;
26  import org.opencastproject.security.api.JaxbRole;
27  import org.opencastproject.security.api.JaxbUser;
28  import org.opencastproject.security.api.Organization;
29  import org.opencastproject.security.api.OrganizationDirectoryService;
30  import org.opencastproject.security.api.Role;
31  import org.opencastproject.security.api.RoleProvider;
32  import org.opencastproject.security.api.SecurityService;
33  import org.opencastproject.security.api.User;
34  import org.opencastproject.security.api.UserProvider;
35  import org.opencastproject.util.NotFoundException;
36  import org.opencastproject.util.OsgiUtil;
37  
38  import com.fasterxml.jackson.databind.JsonNode;
39  import com.fasterxml.jackson.databind.ObjectMapper;
40  import com.google.common.cache.CacheBuilder;
41  import com.google.common.cache.CacheLoader;
42  import com.google.common.cache.LoadingCache;
43  
44  import org.apache.commons.lang3.StringUtils;
45  import org.apache.commons.lang3.math.NumberUtils;
46  import org.apache.http.client.fluent.Content;
47  import org.apache.http.client.fluent.Request;
48  import org.osgi.service.cm.ConfigurationException;
49  import org.osgi.service.component.ComponentContext;
50  import org.osgi.service.component.annotations.Activate;
51  import org.osgi.service.component.annotations.Component;
52  import org.osgi.service.component.annotations.Reference;
53  import org.slf4j.Logger;
54  import org.slf4j.LoggerFactory;
55  
56  import java.io.IOException;
57  import java.io.UnsupportedEncodingException;
58  import java.net.URLEncoder;
59  import java.util.ArrayList;
60  import java.util.Arrays;
61  import java.util.Collections;
62  import java.util.HashSet;
63  import java.util.Iterator;
64  import java.util.List;
65  import java.util.Set;
66  import java.util.concurrent.TimeUnit;
67  import java.util.stream.Collectors;
68  
69  @Component(
70      property = {
71          "service.description=Provides for Canvas users and roles"
72      },
73      immediate = true,
74      service = {UserProvider.class, RoleProvider.class}
75  )
76  public class CanvasUserRoleProvider implements UserProvider, RoleProvider {
77  
78    private static final Logger logger = LoggerFactory.getLogger(CanvasUserRoleProvider.class);
79  
80    private static final String LTI_LEARNER_ROLE = "Learner";
81    private static final String LTI_INSTRUCTOR_ROLE = "Instructor";
82    private static final String PROVIDER_NAME = "canvas";
83  
84    /** The key to look up the organization identifier in the service configuration properties */
85    private static final String ORGANIZATION_KEY = "org.opencastproject.userdirectory.canvas.org";
86    private static final String DEFAULT_ORGANIZATION_VALUE = "mh_default_org";
87  
88    /** The key to look up the URL of Canvas instance */
89    private static final String CANVAS_URL_KEY = "org.opencastproject.userdirectory.canvas.url";
90    /** The key to look up the token of the user to invoke RESTful service of Canvas */
91    private static final String CANVAS_USER_TOKEN_KEY = "org.opencastproject.userdirectory.canvas.token";
92  
93    private static final String CACHE_SIZE_KEY = "org.opencastproject.userdirectory.canvas.cache.size";
94    private static final Integer DEFAULT_CACHE_SIZE_VALUE = 1000;
95    private static final String CACHE_EXPIRATION_KEY = "org.opencastproject.userdirectory.canvas.cache.expiration";
96    private static final Integer DEFAULT_CACHE_EXPIRATION_VALUE = 60;
97  
98    /** The keys to look up which roles in Canvas should be considered as instructor roles */
99    private static final String CANVAS_INSTRUCTOR_ROLES_KEY = "org.opencastproject.userdirectory.canvas.instructor.roles";
100   private static final String DEFAULT_CANVAS_INSTRUCTOR_ROLES = "teacher,ta";
101   /** The keys to look up which users should be ignored */
102   private static final String IGNORED_USERNAMES_KEY = "org.opencastproject.userdirectory.canvas.ignored.usernames";
103   private static final String DEFAULT_INGROED_USERNAMES = "admin,anonymous";
104 
105   private Organization org;
106   private String url;
107   private String token;
108   private int cacheSize;
109   private int cacheExpiration;
110   private Set<String> instructorRoles;
111   private Set<String> ignoredUsernames;
112   private LoadingCache<String, Object> cache = null;
113   private final Object nullToken = new Object();
114 
115   @Activate
116   public void activate(ComponentContext cc) throws ConfigurationException {
117     loadConfig(cc);
118     logger.info(
119         "Activating CanvasUserRoleProvider(url={}, cacheSize={}, cacheExpiration={}, "
120             + "instructorRoles={}, ignoredUserNames={}",
121         url, cacheSize, cacheExpiration, instructorRoles, ignoredUsernames
122     );
123     cache = CacheBuilder.newBuilder()
124         .maximumSize(cacheSize)
125         .expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
126         .build(new CacheLoader<String, Object>() {
127           @Override
128           public Object load(String id) {
129             User user = loadUserFromCanvas(id);
130             return user == null ? nullToken : user;
131           }
132         });
133   }
134 
135   private OrganizationDirectoryService orgDirectory;
136 
137   @Reference
138   public void setOrgDirectory(OrganizationDirectoryService orgDirectory) {
139     this.orgDirectory = orgDirectory;
140   }
141 
142   private SecurityService securityService;
143 
144   @Reference
145   public void setSecurityService(SecurityService securityService) {
146     this.securityService = securityService;
147   }
148 
149 
150   private void loadConfig(ComponentContext cc) throws ConfigurationException {
151     String orgStr = OsgiUtil.getComponentContextProperty(cc, ORGANIZATION_KEY, DEFAULT_ORGANIZATION_VALUE);
152     try {
153       org = orgDirectory.getOrganization(orgStr);
154     } catch (NotFoundException e) {
155       logger.warn("Organization {} not found!", orgStr);
156       throw new ConfigurationException(ORGANIZATION_KEY, "not found");
157     }
158     url = OsgiUtil.getComponentContextProperty(cc, CANVAS_URL_KEY);
159     if (url.endsWith("/")) {
160       url = StringUtils.chop(url);
161     }
162     logger.debug("Canvas URL: {}", url);
163     token = OsgiUtil.getComponentContextProperty(cc, CANVAS_USER_TOKEN_KEY);
164 
165     String cacheSizeStr = OsgiUtil.getComponentContextProperty(
166         cc, CACHE_SIZE_KEY, DEFAULT_CACHE_SIZE_VALUE.toString());
167     cacheSize = NumberUtils.toInt(cacheSizeStr);
168     String cacheExpireStr = OsgiUtil.getComponentContextProperty(
169         cc, CACHE_EXPIRATION_KEY, DEFAULT_CACHE_EXPIRATION_VALUE.toString());
170     cacheExpiration = NumberUtils.toInt(cacheExpireStr);
171 
172     String rolesStr = OsgiUtil.getComponentContextProperty(
173         cc, CANVAS_INSTRUCTOR_ROLES_KEY, DEFAULT_CANVAS_INSTRUCTOR_ROLES);
174     instructorRoles = parsePropertyLineAsSet(rolesStr);
175     logger.debug("Canvas instructor roles: {}", instructorRoles);
176 
177     String ignoredUsersStr = OsgiUtil.getComponentContextProperty(
178         cc, IGNORED_USERNAMES_KEY, DEFAULT_INGROED_USERNAMES);
179     ignoredUsernames = parsePropertyLineAsSet(ignoredUsersStr);
180     logger.debug("Ignored users: {}", ignoredUsernames);
181   }
182 
183   @Override
184   public List<Role> getRolesForUser(String userName) {
185     logger.debug("getRolesForUser({})", userName);
186 
187     List<Role> roles = new ArrayList<>();
188 
189     if (ignoredUsernames.contains(userName)) {
190       logger.debug("We don't answer for: {}", userName);
191       return roles;
192     }
193 
194     User user = loadUser(userName);
195     if (user != null) {
196       logger.debug("Returning cached rolset for {}", userName);
197       return new ArrayList<>(user.getRoles());
198     }
199     logger.debug("Return empty roleset for {} - not found in Canvas", userName);
200     return Collections.emptyList();
201   }
202 
203   @Override
204   public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
205     logger.debug("findRoles(query={} offset={} limit={})", query, offset, limit);
206     if (target == Role.Target.USER) {
207       return Collections.emptyIterator();
208     }
209     boolean exact = true;
210     if (query.endsWith("%")) {
211       exact = false;
212       query = StringUtils.chop(query);
213     }
214 
215     if (query.isEmpty()) {
216       return Collections.emptyIterator();
217     }
218 
219     if (exact && !query.endsWith("_" + LTI_LEARNER_ROLE) && !query.endsWith("_" + LTI_INSTRUCTOR_ROLE)) {
220       return Collections.emptyIterator();
221     }
222 
223     List<Role> roles = new ArrayList<>();
224     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(org);
225     for (String siteRole: getCanvasSiteRolesByCurrentUser(query, exact)) {
226       roles.add(new JaxbRole(siteRole, jaxbOrganization, "Canvas Site Role", Role.Type.EXTERNAL));
227     }
228     return roles.iterator();
229   }
230 
231   @Override
232   public String getName() {
233     return PROVIDER_NAME;
234   }
235 
236   @Override
237   public Iterator<User> getUsers() {
238     return Collections.emptyIterator();
239   }
240 
241   @Override
242   public User loadUser(String userName) {
243     logger.debug("loadUser({})", userName);
244     Object user = cache.getUnchecked(userName);
245     if (user == nullToken) {
246       logger.debug("Returning null user from cache");
247       return null;
248     } else {
249       logger.debug("Returning user {} from cache", userName);
250       return (JaxbUser) user;
251     }
252   }
253 
254   @Override
255   public long countUsers() {
256     return 0;
257   }
258 
259   @Override
260   public String getOrganization() {
261     return org.getId();
262   }
263 
264   @Override
265   public Iterator<User> findUsers(String query, int offset, int limit) {
266     if (query == null) {
267       throw new IllegalArgumentException("Query must be set");
268     }
269 
270     if (query.endsWith("%")) {
271       query = query.substring(0, query.length() - 1);
272     }
273     if (query.isEmpty()) {
274       return Collections.emptyIterator();
275     }
276 
277     if (!verifyCanvasUser(query)) {
278       return Collections.emptyIterator();
279     }
280 
281     List<User> users = new ArrayList<>();
282     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(org);
283     JaxbUser queryUser = new JaxbUser(query, PROVIDER_NAME, jaxbOrganization, new HashSet<>());
284     users.add(queryUser);
285     return users.iterator();
286   }
287 
288   @Override
289   public void invalidate(String userName) {
290     cache.invalidate(userName);
291   }
292 
293   private User loadUserFromCanvas(String userName) {
294     if (cache == null) {
295       throw new IllegalArgumentException("The Canvas user detail service has not yet been configured");
296     }
297 
298     if (ignoredUsernames.contains(userName)) {
299       cache.put(userName, nullToken);
300       logger.debug("We don't answer for: {}", userName);
301       return null;
302     }
303 
304     logger.debug("In loadUserFromCanvas, currently processing user: {}", userName);
305     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(org);
306     String[] canvasUserInfo = getCanvasUserInfo(userName);
307     if (canvasUserInfo == null) {
308       cache.put(userName, nullToken);
309       return null;
310     }
311     String email = canvasUserInfo[0];
312     String displayName = canvasUserInfo[1];
313 
314     List<String> canvasRoles = getRolesFromCanvas(userName);
315     if (canvasRoles == null) {
316       cache.put(userName, nullToken);
317       return null;
318     }
319 
320     logger.debug("Canvas roles for {}: {}", userName, canvasRoles);
321 
322     Set<JaxbRole> roles = new HashSet<>();
323     boolean isInstructor = false;
324     for (String roleStr: canvasRoles) {
325       roles.add(new JaxbRole(roleStr, jaxbOrganization, "Canvas external role", Role.Type.EXTERNAL));
326       if (roleStr.endsWith(LTI_INSTRUCTOR_ROLE)) {
327         isInstructor = true;
328       }
329     }
330     roles.add(new JaxbRole(Group.ROLE_PREFIX + "CANVAS", jaxbOrganization, "Canvas User",
331             Role.Type.EXTERNAL_GROUP));
332     if (isInstructor) {
333       roles.add(new JaxbRole(Group.ROLE_PREFIX + "CANVAS_INSTRUCTOR", jaxbOrganization, "Canvas Instructor",
334                 Role.Type.EXTERNAL_GROUP));
335     }
336     logger.debug("Returning JaxbRoles: {}", roles);
337 
338     User user = new JaxbUser(userName, null, displayName, email, PROVIDER_NAME, jaxbOrganization, roles);
339     cache.put(userName, user);
340     logger.debug("Returning user {}", userName);
341     return user;
342   }
343 
344   private String[] getCanvasUserInfo(String userName) {
345     try {
346       userName = URLEncoder.encode(userName, "UTF-8");
347     } catch (UnsupportedEncodingException e) {
348       // Should never be happen, UTF-8 is always supported
349     }
350     String urlString = String.format("%s/api/v1/users/sis_login_id:%s", url, userName);
351     try {
352       JsonNode node = getRequestJson(urlString);
353 
354       String email = node.path("email").asText();
355       String displayName = node.path("name").asText();
356       return new String[]{email, displayName};
357     } catch (IOException e) {
358       logger.warn("Exception getting Canvas user information for user {} at {}", userName, urlString, e);
359     }
360     return null;
361   }
362 
363   private List<String> getRolesFromCanvas(String userName) {
364     logger.debug("getRolesFromCanvas({})", userName);
365     try {
366       userName = URLEncoder.encode(userName, "UTF-8");
367     } catch (UnsupportedEncodingException e) {
368       // Should never be happen, UTF-8 is always supported
369     }
370     // Only list 'active' enrollments. That means, only courses in active terms will be used.
371     String urlString = String.format(
372         "%s/api/v1/users/sis_login_id:%s/courses.json?per_page=500&enrollment_state=active&"
373             + "state[]=unpublished&state[]=available",
374         url,
375         userName
376     );
377     try {
378       List<String> roleList = new ArrayList<>();
379       JsonNode nodes = getRequestJson(urlString);
380       for (JsonNode node: nodes) {
381         String courseId = node.path("id").asText();
382         JsonNode enrollmentNodes = node.path("enrollments");
383         for (JsonNode enrollmentNode: enrollmentNodes) {
384           String canvasRole = enrollmentNode.path("type").asText();
385           String ltiRole = instructorRoles.contains(canvasRole) ? LTI_INSTRUCTOR_ROLE : LTI_LEARNER_ROLE;
386           String opencastRole = String.format("%s_%s", courseId, ltiRole);
387           roleList.add(opencastRole);
388         }
389       }
390       return roleList;
391 
392     } catch (IOException e) {
393       logger.warn("Exception getting site/role membership for Canvas user {} at {}", userName, urlString, e);
394     }
395     return null;
396   }
397 
398   private JsonNode getRequestJson(String urlString) throws IOException {
399     Content content = Request.Get(urlString).addHeader("Authorization", "Bearer " + token)
400         .execute().returnContent();
401     ObjectMapper mapper = new ObjectMapper();
402     return mapper.readTree(content.asStream());
403   }
404 
405   private boolean verifyCanvasUser(String userName) {
406     logger.debug("verifyCanvasUser({})", userName);
407     try {
408       userName = URLEncoder.encode(userName, "UTF-8");
409     } catch (UnsupportedEncodingException e) {
410       // Should never be happen, UTF-8 is always supported
411     }
412     String urlString = String.format("%s/api/v1/users/sis_login_id:%s", url, userName);
413     try {
414       getRequestJson(urlString);
415     } catch (IOException e) {
416       return false;
417     }
418     return true;
419   }
420 
421   private Set<String> parsePropertyLineAsSet(String configLine) {
422     Set<String> set = new HashSet<>();
423     String[] configs = configLine.split(",");
424     for (String config: configs) {
425       set.add(config.trim());
426     }
427     return set;
428   }
429 
430   /**
431    * Get all sites id taught by current user.
432    * Only list available site roles for current user.
433    */
434   private List<String> getCanvasSiteRolesByCurrentUser(String query, boolean exact) {
435     User user = securityService.getUser();
436     if (exact) {
437       Set<String> roles = user.getRoles().stream().map(Role::getName).collect(Collectors.toSet());
438       if (roles.contains(query)) {
439         return Collections.singletonList(query);
440       } else {
441         return Collections.emptyList();
442       }
443     }
444 
445     final String finalQuery = StringUtils.chop(query);
446 
447     return user.getRoles().stream()
448         .map(Role::getName)
449         .filter(roleName -> roleName.endsWith("_" + LTI_INSTRUCTOR_ROLE) || roleName.endsWith("_" + LTI_LEARNER_ROLE))
450         .map(roleName -> StringUtils.substringBeforeLast(roleName, "_"))
451         .distinct()
452         .map(site -> Arrays.asList(site + "_" + LTI_LEARNER_ROLE, site + "_" + LTI_INSTRUCTOR_ROLE))
453         .flatMap(siteRoles -> siteRoles.stream())
454         .filter(roleName -> roleName.startsWith(finalQuery))
455         .collect(Collectors.toList());
456   }
457 
458 }