1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
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
89 private static final String CANVAS_URL_KEY = "org.opencastproject.userdirectory.canvas.url";
90
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
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
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
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
369 }
370
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
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
432
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 }