diff --git a/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt b/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt index b258893..8d72d4b 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt @@ -14,10 +14,11 @@ class CacheConfig { fun cacheManager(): CacheManager { return ConcurrentMapCacheManager( "calculateAvgTaskPoints", - "getUserByUsername", - "getUserRoles", "getStudent", - "getStudentWithPoints" + "getStudentWithPoints", + "userRoles", + "usernameForLogin", + "getAllUserIdsFor" ) } } \ No newline at end of file diff --git a/src/main/kotlin/ch/uzh/ifi/access/config/SecurityConfig.kt b/src/main/kotlin/ch/uzh/ifi/access/config/SecurityConfig.kt index b5dadd7..061f1fa 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/config/SecurityConfig.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/config/SecurityConfig.kt @@ -1,16 +1,12 @@ package ch.uzh.ifi.access.config -import ch.uzh.ifi.access.service.CourseService -import ch.uzh.ifi.access.service.RoleService import io.github.oshai.kotlinlogging.KotlinLogging import lombok.AllArgsConstructor import org.keycloak.admin.client.Keycloak import org.keycloak.admin.client.resource.RealmResource -import org.springframework.context.ApplicationListener import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.env.Environment -import org.springframework.security.authentication.event.AuthenticationSuccessEvent import org.springframework.security.authorization.AuthorityAuthorizationDecision import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity @@ -23,14 +19,8 @@ import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.access.intercept.RequestAuthorizationContext -import org.springframework.stereotype.Component import org.springframework.web.filter.CommonsRequestLoggingFilter import java.nio.file.Path -import java.time.Duration -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId @AllArgsConstructor @@ -143,43 +133,3 @@ class SecurityConfig(private val env: Environment) { return keycloakClient.realm("access") } } - -@Component -class AuthenticationSuccessListener( - val courseService: CourseService, - val roleService: RoleService -) : ApplicationListener { - - private val logger = KotlinLogging.logger {} - - override fun onApplicationEvent(event: AuthenticationSuccessEvent) { - val username = event.authentication.name - logger.debug { "USER [$username] LOGGED IN" } - roleService.getUserRepresentationForUsername(username)?.let { user -> - // TODO: clean up this horrible mess - val currentAttributes = user.attributes ?: mutableMapOf() - if (user.attributes?.containsKey("roles_synced_at") != true) { - logger.debug { "syncing $username to courses for the first time" } - courseService.updateStudentRoles(username) - } - else { - try { - val lastSync = currentAttributes["roles_synced_at"]!!.first() - val lastSyncInstant = LocalDateTime.parse(lastSync) - val now = LocalDateTime.now() - val diff = Duration.between(lastSyncInstant, now).toMinutes() - if (diff > 10) { - logger.debug { "syncing $username to courses after more than 10min" } - courseService.updateStudentRoles(username) - } - else { - logger.debug { "only $diff minutes elapsed since last sync of $username at ${lastSync} (now: $now)" } - } - } catch (e: Exception) { - logger.debug { "problem ($e, ${e.stackTrace}) with sync calculation; syncing $username to courses anyway" } - courseService.updateStudentRoles(username) - } - } - } - } -} diff --git a/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt b/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt index ca133a4..6209f96 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt @@ -8,10 +8,13 @@ import ch.uzh.ifi.access.service.CourseServiceForCaching import ch.uzh.ifi.access.service.RoleService import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.http.HttpStatus +import org.springframework.scheduling.annotation.EnableAsync import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException +import java.time.LocalDateTime +import java.util.concurrent.Semaphore @RestController @@ -75,6 +78,7 @@ class WebhooksController( @RestController @RequestMapping("/courses") +@EnableAsync class CourseController ( private val courseService: CourseService, private val courseServiceForCaching: CourseServiceForCaching, @@ -82,14 +86,48 @@ class CourseController ( ) { + private val logger = KotlinLogging.logger {} + @PostMapping("/{course}/pull") @PreAuthorize("hasRole(#course+'-supervisor')") fun updateCourse(@PathVariable course: String?): String? { return courseService.updateCourse(course!!).slug } + private val semaphore = Semaphore(1) + private fun updateRoles(authentication: Authentication) { + val username = authentication.name + try { + semaphore.acquire() + roleService.getUserRepresentationForUsername(username)?.let { user -> + val attributes = user.attributes ?: mutableMapOf() + if (attributes["roles_synced_at"] == null) { + user.attributes = attributes + roleService.getUserResourceById(user.id).update(user) + val searchNames = buildList { + add(user.username) + user.email?.let { add(it) } + attributes["swissEduIDLinkedAffiliationMail"]?.let { addAll(it) } + attributes["swissEduIDAssociatedMail"]?.let { addAll(it) } + } + val roles = courseService.getUserRoles(searchNames) + roleService.setFirstLoginRoles(user, roles) + logger.debug { "Enrolled first-time user $username in their courses" } + attributes["roles_synced_at"] = listOf(LocalDateTime.now().toString()) + } + } + } catch (e: Exception) { + logger.error { "Error looking up user $username: ${e.message}" } + throw e + } finally { + semaphore.release() + logger.debug { "Released semaphore (${semaphore.queueLength} waiting, ${semaphore.availablePermits()} available) for user lookup: $username" } + } + } + @GetMapping("") - fun getCourses(): List { + fun getCourses(authentication: Authentication): List { + updateRoles(authentication) return courseService.getCoursesOverview() } @@ -148,28 +186,32 @@ class CourseController ( .filter { it.email != null && it.firstName != null && it.lastName != null } } - @PostMapping("/{course}/participants") - fun registerParticipants(@PathVariable course: String, @RequestBody students: List) { - // set list of course students - courseService.setStudents(course, students) - // update keycloak roles - roleService.updateStudentRoles(course, students.toSet(), - Role.STUDENT.withCourse(course)) + + //@Transactional + private fun assignRoles(slug: String, loginNames: List, role: Role) { + val assessment = courseService.getCourseBySlug(slug) + // Saves the list of usernames in the database + val (remove, add) = courseService.setRoleUsers(assessment, loginNames, role) + // Grants the correct role to any existing users in usernames + // This is one of two ways a keycloak user can receive/lose a role, the other way is on first login. + roleService.setRoleUsers(assessment, remove, add, role) + //return courseService.getAssessmentDetailsBySlug(slug) } @PostMapping("/{course}/assistants") - //@PreAuthorize("hasRole('supervisor')") - fun registerAssistants(@PathVariable course: String, @RequestBody assistants: List) { - // set list of course students - courseService.setAssistants(course, assistants) - // update keycloak roles - roleService.updateStudentRoles(course, assistants.toSet(), - Role.ASSISTANT.withCourse(course)) + //@PreAuthorize("hasAuthority('API_KEY') or hasRole('owner')") + fun registerAssistants(@PathVariable course: String, @RequestBody usernames: List) { + return assignRoles(course, usernames, Role.ASSISTANT) + } + + @PostMapping("/{course}/participants") + fun registerParticipants(@PathVariable course: String, @RequestBody participants: List) { + return assignRoles(course, participants, Role.STUDENT) } @GetMapping("/{course}/participants/{participant}") fun getCourseProgress(@PathVariable course: String, @PathVariable participant: String): CourseProgressDTO? { - val user = roleService.getUserByUsername(participant)?: + val user = roleService.getUserRepresentationForUsername(participant)?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No participant $participant") return courseService.getCourseProgress(course, user.username) } @@ -180,7 +222,7 @@ class CourseController ( @PathVariable assignment: String, @PathVariable participant: String ): AssignmentProgressDTO? { - val user = roleService.getUserByUsername(participant)?: + val user = roleService.getUserRepresentationForUsername(participant)?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No participant $participant") return courseService.getAssignmentProgress(course, assignment, user.username) } @@ -190,7 +232,7 @@ class CourseController ( @PathVariable course: String, @PathVariable assignment: String, @PathVariable task: String, @PathVariable participant: String ): TaskProgressDTO? { - val user = roleService.getUserByUsername(participant)?: + val user = roleService.getUserRepresentationForUsername(participant)?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No participant $participant") return courseService.getTaskProgress(course, assignment, task, user.username) } diff --git a/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt b/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt index b4f3adc..020c2d0 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt @@ -20,10 +20,10 @@ interface AssignmentOverview { val countDown: List? val isPastDue: Boolean val isActive: Boolean - @get:Value("#{@courseService.calculateAssignmentMaxPoints(target.tasks, null)}") + @get:Value("#{@courseService.calculateAssignmentMaxPoints(target.tasks}") val maxPoints: Double? - @get:Value("#{@courseService.calculateAssignmentPoints(target.tasks, null)}") + @get:Value("#{@courseService.calculateAssignmentPoints(target.tasks)}") val points: Double? @get:Value("#{target.tasks.size()}") diff --git a/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentWorkspace.kt b/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentWorkspace.kt index 78e42fe..33c2bbb 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentWorkspace.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentWorkspace.kt @@ -42,7 +42,7 @@ interface AssignmentWorkspace { @get:Value("#{@courseService.calculateAssignmentMaxPoints(target.tasks, null)}") val maxPoints: Double? - @get:Value("#{@courseService.calculateAssignmentPoints(target.tasks, null)}") + @get:Value("#{@courseService.calculateAssignmentPoints(target.tasks)}") val points: Double? @get:Value("#{@courseService.enabledTasksOnly(target.tasks)}") diff --git a/src/main/kotlin/ch/uzh/ifi/access/projections/TaskOverview.kt b/src/main/kotlin/ch/uzh/ifi/access/projections/TaskOverview.kt index e07e281..461e9d3 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/projections/TaskOverview.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/projections/TaskOverview.kt @@ -24,7 +24,7 @@ interface TaskOverview { @get:Value("#{@courseService.calculateAvgTaskPoints(target.slug)}") val avgPoints: Double? - @get:Value("#{@courseService.calculateTaskPoints(target.id, target.userId)}") + @get:Value("#{@courseService.calculateTaskPoints(target.id, {target.userId})}") val points: Double? @get:Value("#{@courseService.getRemainingAttempts(target.id, target.userId, target.maxAttempts)}") diff --git a/src/main/kotlin/ch/uzh/ifi/access/repository/CourseRepository.kt b/src/main/kotlin/ch/uzh/ifi/access/repository/CourseRepository.kt index 111b554..a3e44fe 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/repository/CourseRepository.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/repository/CourseRepository.kt @@ -47,4 +47,8 @@ interface CourseRepository : JpaRepository { ) fun getTotalPoints(courseSlug: String, userIds: Array): Double? + // Bypasses role restrictions, use only if preventing leaks by other means. + // Necessary for retrieving user roles upon first login. + fun findAllUnrestrictedByDeletedFalse(): List + } \ No newline at end of file diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt index b5d4e4a..b11801b 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt @@ -2,6 +2,7 @@ package ch.uzh.ifi.access.service import ch.uzh.ifi.access.model.* import ch.uzh.ifi.access.model.constants.Command +import ch.uzh.ifi.access.model.constants.Role import ch.uzh.ifi.access.model.dao.Rank import ch.uzh.ifi.access.model.dao.Results import ch.uzh.ifi.access.model.dto.* @@ -54,8 +55,8 @@ class CourseServiceForCaching( fun getStudents(courseSlug: String): List { val course = courseService.getCourseBySlug(courseSlug) - return course.registeredStudents.map { - val user = roleService.getUserByUsername(it) + return course.registeredStudents.map { + val user = roleService.getUserRepresentationForUsername(it) if (user != null) { val studentDTO = courseService.getStudent(courseSlug, user) studentDTO.username = user.username @@ -70,7 +71,7 @@ class CourseServiceForCaching( fun getStudentsWithPoints(courseSlug: String): List { val course = courseService.getCourseBySlug(courseSlug) return course.registeredStudents.map { - val user = roleService.getUserByUsername(it) + val user = roleService.getUserRepresentationForUsername(it) if (user != null) { // TODO!: make sure evaluations are saved under only a single user ID in the future! // for now, retrieve all possible user IDs from keycloak and retrieve all matching evaluations @@ -238,6 +239,20 @@ class CourseService( }.toList() } + @Transactional + fun getUserRoles(usernames: List): List { + return courseRepository.findAllUnrestrictedByDeletedFalse().flatMap { course -> + val slug = course.slug + usernames.flatMap { username -> + listOfNotNull( + if (course.supervisors.contains(username)) "$slug-supervisor" else null, + if (course.assistants.contains(username)) "$slug-assistant" else null, + if (course.registeredStudents.contains(username)) "$slug-student" else null, + ) + } + } + } + @Cacheable(value = ["calculateAvgTaskPoints"], key = "#taskSlug") fun calculateAvgTaskPoints(taskSlug: String?): Double { return 0.0 @@ -246,16 +261,24 @@ class CourseService( } fun calculateTaskPoints(taskId: Long?, userId: String?): Double { - // TODO!: make sure evaluations are saved under only a single user ID in the future! - // for now, retrieve all possible user IDs from keycloak and retrieve all matching evaluations val userIds = roleService.getAllUserIdsFor(verifyUserId(userId)) + return calculateTaskPoints(taskId, userIds) + } + + fun calculateTaskPoints(taskId: Long?, userIds: List): Double { + // for now, retrieve all possible user IDs from keycloak and retrieve all matching evaluations return userIds.maxOfOrNull { getEvaluation(taskId, verifyUserId(it))?.bestScore ?: 0.0 } ?: 0.0 } - fun calculateAssignmentPoints(tasks: List, userId: String?): Double { - return tasks.stream().mapToDouble { task: Task -> calculateTaskPoints(task.id, userId) }.sum() + fun calculateAssignmentPoints(tasks: List): Double { + val userIds = roleService.getAllUserIdsFor(verifyUserId(null)) + return calculateAssignmentPoints(tasks, userIds) + + } + fun calculateAssignmentPoints(tasks: List, userIds: List): Double { + return tasks.stream().mapToDouble { task: Task -> calculateTaskPoints(task.id, userIds) }.sum() } fun calculateAssignmentMaxPoints(tasks: List, userId: String?): Double { @@ -263,8 +286,9 @@ class CourseService( } fun calculateCoursePoints(assignments: List, userId: String?): Double { + val userIds = roleService.getAllUserIdsFor(verifyUserId(userId)) return assignments.stream() - .mapToDouble { assignment: Assignment -> calculateAssignmentPoints(assignment.tasks, userId) } + .mapToDouble { assignment: Assignment -> calculateAssignmentPoints(assignment.tasks, userIds) } .sum() } @@ -719,8 +743,10 @@ exit ${'$'}exit_code; @Transactional @Caching(evict = [ - CacheEvict(value = ["getUserByUsername"], key = "#username"), - CacheEvict(value = ["getUserRoles"], key = "#username") + CacheEvict(value = ["usernameForLogin"], key = "#username"), + CacheEvict(value = ["userRoles"], key = "#username"), + CacheEvict(value = ["usernameForLogin"], key = "#username"), + CacheEvict(value = ["getAllUserIdsFor"], key = "#username"), ]) fun updateStudentRoles(username: String) { logger.debug { "CourseService updating ${username} roles for ${getCourses().size} courses" } @@ -730,6 +756,35 @@ exit ${'$'}exit_code; } } + @Transactional + @Caching(evict = [ + CacheEvict(value = ["userRoles"], allEntries = true), + ]) + fun setRoleUsers(course: Course, usernames: List, role: Role): Pair, List> { + val existingUsers = when (role) { + Role.SUPERVISOR -> course.supervisors + Role.ASSISTANT -> course.assistants + Role.STUDENT -> course.registeredStudents + } + + val newUsersSet = usernames.toSet() + val existingUsersSet = existingUsers.toSet() + + val removedUsers = existingUsersSet.minus(newUsersSet).toList() + val addedUsers = newUsersSet.minus(existingUsersSet).toList() + + when (role) { + Role.SUPERVISOR -> course.supervisors = newUsersSet.toMutableSet() + Role.ASSISTANT -> course.assistants = newUsersSet.toMutableSet() + Role.STUDENT -> course.registeredStudents = newUsersSet.toMutableSet() + } + + courseRepository.save(course) + + return Pair(removedUsers, addedUsers) + } + + @Cacheable(value = ["getStudent"], key = "#courseSlug + '-' + #user.username") fun getStudent(courseSlug: String, user: UserRepresentation): StudentDTO { return StudentDTO(user.firstName, user.lastName, user.email) @@ -860,4 +915,4 @@ exit ${'$'}exit_code; else -> String.format("%.2f GB", sizeInBytes / gigabyte) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt index 7355125..7c38104 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt @@ -6,9 +6,12 @@ import ch.uzh.ifi.access.model.dto.MemberDTO import io.github.oshai.kotlinlogging.KotlinLogging import org.apache.commons.collections4.SetUtils import org.keycloak.admin.client.resource.RealmResource +import org.keycloak.admin.client.resource.UserResource +import org.keycloak.admin.client.resource.UsersResource import org.keycloak.representations.idm.RoleRepresentation import org.keycloak.representations.idm.RoleRepresentation.Composites import org.keycloak.representations.idm.UserRepresentation +import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.Cacheable import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder @@ -24,6 +27,16 @@ class RoleService( private val logger = KotlinLogging.logger {} + companion object { + private const val SEARCH_LIMIT = 10 + private val ATTRIBUTE_KEYS = listOf( + "swissEduIDLinkedAffiliationUniqueID", + "swissEduIDLinkedAffiliationMail", + "swissEduPersonUniqueID" + ) + } + + fun getCurrentUser(): String { val authentication: Authentication = SecurityContextHolder.getContext().authentication return authentication.name @@ -43,6 +56,138 @@ class RoleService( return resByEmail } + @CacheEvict("userRoles", allEntries = true) + fun setFirstLoginRoles(user: UserRepresentation, roles: List) { + // this method does never *removes* any roles, so it can only work correctly for the first login + accessRealm.users()[user.id].roles().realmLevel().add(roles.map { + accessRealm.roles()[it].toRepresentation() + }) + + } + + fun getUserResourceById(userId: String): UserResource { + return accessRealm.users().get(userId) + } + + @Cacheable("usernameForLogin", key = "#username") // TODO: evict this somehwere? + fun usernameForLogin(username: String): String { + if (username.isBlank()) return username + + return findUserByAllCriteria(username)?.username ?: username + } + + private fun findUserByAllCriteria(login: String): UserRepresentation? { + val usersResource = accessRealm.users() + findUserByUsername(usersResource, login)?.let { return it } + findUserByEmail(usersResource, login)?.let { return it } + findUserByAttributes(usersResource, login)?.let { return it } + return null + } + + private fun findUserByUsername(users: UsersResource, login: String): UserRepresentation? { + return try { + users.search(login, 0, 1) // exact match, limit 1 + .firstOrNull() + } catch (e: Exception) { + logger.warn { "Error searching by username: ${e.message}" } + null + } + } + + private fun findUserByEmail(users: UsersResource, login: String): UserRepresentation? { + return try { + users.search(null, null, null, login, 0, 1) + .firstOrNull() + } catch (e: Exception) { + logger.warn { "Error searching by email: ${e.message}" } + null + } + } + + private fun findUserByAttributes(users: UsersResource, login: String): UserRepresentation? { + return try { + val attributeQueries = ATTRIBUTE_KEYS.joinToString(" OR ") { key -> + "\"$key\":\"$login\"" + } + val matchingUsers = users.searchByAttributes(0, SEARCH_LIMIT, attributeQueries) + // Verify the match (since searchByAttributes might return partial matches) + matchingUsers.firstOrNull { user -> + ATTRIBUTE_KEYS.any { key -> + user.attributes?.get(key)?.any { it == login } == true + } + } + } catch (e: Exception) { + logger.warn { "Error searching by attributes: ${e.message}" } + null + } + } + + private fun UsersResource.searchByAttributes( + firstResult: Int, + maxResults: Int, + attributeQuery: String + ): List { + return try { + // Use Keycloak's search API with attribute query + search(attributeQuery, firstResult, maxResults) + } catch (e: Exception) { + logger.error { "Failed to search by attributes: ${e.message}" } + emptyList() + } + } + + + @CacheEvict("userRoles", allEntries = true) + fun setRoleUsers(course: Course, toRemove: List, toAdd: List, role: Role) { + val roleName = role.withCourse(course.slug) + val realmRole = accessRealm.roles()[roleName] + val realmRoleRepresentation = realmRole.toRepresentation() + // remove role from users which are not in usernames list + logger.debug { "removing users to $course: $toRemove"} + toRemove.forEach { login -> + val username = usernameForLogin(login) + try { + // Search for exact username match using search query + val users = accessRealm.users() + .searchByUsername(username, true) + + val user = users.firstOrNull() + if (user != null) { + logger.debug { "Removing role $roleName from ${user.username}"} + accessRealm.users()[user.id].roles().realmLevel().remove(listOf(realmRoleRepresentation)) + } else { + logger.warn { "User with username $username not found" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to remove role $roleName to user $username" } + } + } + // add role to all users in usernames list + logger.debug { "adding users to $course: ${toAdd}"} + toAdd.forEach { login -> + val username = usernameForLogin(login) + try { + // Search for exact username match using search query + val users = accessRealm.users() + .searchByUsername(username, true) + + val user = users.firstOrNull() + if (user != null) { + logger.debug { "Adding role $roleName to ${user.username}" } + accessRealm.users()[user.id].roles() + .realmLevel() + .add(listOf(realmRoleRepresentation)) + } else { + logger.warn { "User with username $username not found" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to add role $roleName to user $username" } + } + } + } + + + fun createCourseRoles(courseSlug: String?): String? { val studentRole = Role.STUDENT.withCourse(courseSlug) return accessRealm.roles().list(courseSlug, true).stream() @@ -88,15 +233,7 @@ class RoleService( .getUserMembers(0, -1) } - // TODO: confusing username nomenclature. Is it the ACCESS/Keycloak username or the registration username (usually shibID)? - @Cacheable(value = ["getUserByUsername"], key = "#username") - fun getUserByUsername(username: String): UserRepresentation? { - return accessRealm.users().list(0, -1).firstOrNull { - studentMatchesUser(username, it) - } - } - - @Cacheable(value = ["getUserRoles"], key = "#username") + @Cacheable(value = ["userRoles"], key = "#username") fun getUserRoles(username: String, userId: String): MutableList { return accessRealm.users()[userId].roles().realmLevel().listEffective() } @@ -201,6 +338,7 @@ class RoleService( return sessions.size } + @Cacheable("getAllUserIdsFor", key = "#userId") fun getAllUserIdsFor(userId: String): List { val user = getUserRepresentationForUsername(userId) ?: return emptyList() val results = mutableListOf()