From 50dc20b32d1333e42a844322ac496eda6db26126 Mon Sep 17 00:00:00 2001 From: Pauline Date: Wed, 28 Aug 2024 16:00:58 +0100 Subject: [PATCH 01/28] Add kratos webhook endpoint for creating subjects through webhook --- .../radarbase/auth/kratos/KratosSessionDTO.kt | 26 +- .../config/SecurityConfiguration.kt | 2 + .../management/service/IdentityService.kt | 387 ++++++++++-------- .../management/service/SubjectService.kt | 381 ++++++++++------- .../service/dto/KratosSubjectWebhookDTO.kt | 28 ++ .../management/web/rest/KratosEndpoint.kt | 114 ++++++ 6 files changed, 604 insertions(+), 334 deletions(-) create mode 100644 src/main/java/org/radarbase/management/service/dto/KratosSubjectWebhookDTO.kt create mode 100644 src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index 218792df8..805c71a68 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -46,14 +46,14 @@ class KratosSessionDTO( @Serializable class Identity( - val id: String? = null, + var id: String? = null, val schema_id: String? = null, val schema_url: String? = null, val state: String? = null, @Serializable(with = InstantSerializer::class) val state_changed_at: Instant? = null, val traits: Traits? = null, - val metadata_public: Metadata? = null, + var metadata_public: Metadata? = null, @Serializable(with = InstantSerializer::class) val created_at: Instant? = null, @Serializable(with = InstantSerializer::class) @@ -63,16 +63,16 @@ class KratosSessionDTO( fun parseRoles(): Set = buildSet { - if (metadata_public?.authorities?.isNotEmpty() == true) { - for (roleValue in metadata_public.authorities) { + metadata_public?.authorities?.takeIf { it.isNotEmpty() }?.let { authorities -> + for (roleValue in authorities) { val authority = RoleAuthority.valueOfAuthorityOrNull(roleValue) if (authority?.scope == RoleAuthority.Scope.GLOBAL) { add(AuthorityReference(authority)) } } - } - if (metadata_public?.roles?.isNotEmpty() == true) { - for (roleValue in metadata_public.roles) { + } + metadata_public?.roles?.takeIf { it.isNotEmpty() }?.let { roles -> + for (roleValue in roles) { val role = RoleAuthority.valueOfAuthorityOrNull(roleValue) if (role?.scope == RoleAuthority.Scope.GLOBAL) { add(AuthorityReference(role)) @@ -91,12 +91,12 @@ class KratosSessionDTO( @Serializable class Metadata ( - val roles: List, - val authorities: Set, - val scope: List, - val sources: List, - val aud: List, - val mp_login: String? + val roles: List = emptyList(), + val authorities: Set = emptySet(), + val scope: List = emptyList(), + val sources: List = emptyList(), + val aud: List = emptyList(), + val mp_login: String? = null, ) fun toDataRadarToken() : DataRadarToken { diff --git a/src/main/java/org/radarbase/management/config/SecurityConfiguration.kt b/src/main/java/org/radarbase/management/config/SecurityConfiguration.kt index abe0ad339..efa983229 100644 --- a/src/main/java/org/radarbase/management/config/SecurityConfiguration.kt +++ b/src/main/java/org/radarbase/management/config/SecurityConfiguration.kt @@ -87,6 +87,7 @@ class SecurityConfiguration .skipUrlPattern(HttpMethod.GET, "/api/redirect/**") .skipUrlPattern(HttpMethod.GET, "/api/profile-info") .skipUrlPattern(HttpMethod.GET, "/api/logout-url") + .skipUrlPattern(HttpMethod.POST, "/api/kratos/**") .skipUrlPattern(HttpMethod.GET, "/oauth2/authorize") .skipUrlPattern(HttpMethod.GET, "/images/**") .skipUrlPattern(HttpMethod.GET, "/css/**") @@ -111,6 +112,7 @@ class SecurityConfiguration .antMatchers("/api/activate") .antMatchers("/api/sitesettings") .antMatchers("/api/redirect/**") + .antMatchers("/api/kratos/**") .antMatchers("/api/account/reset_password/init") .antMatchers("/api/account/reset_password/finish") .antMatchers("/test/**") diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index 672626188..e0b0bd202 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -8,6 +8,7 @@ import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import java.time.Duration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -16,214 +17,272 @@ import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.exception.IdpException import org.radarbase.auth.kratos.KratosSessionDTO import org.radarbase.management.config.ManagementPortalProperties -import org.radarbase.management.domain.Role import org.radarbase.management.domain.User +import org.radarbase.management.service.dto.UserDTO import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.Duration -/** - * Service class for managing identities. - */ +/** Service class for managing identities. */ @Service @Transactional -class IdentityService( - @Autowired private val managementPortalProperties: ManagementPortalProperties, - @Autowired private val authService: AuthService +class IdentityService +@Autowired +constructor( + private val managementPortalProperties: ManagementPortalProperties, + private val authService: AuthService ) { - private val httpClient = HttpClient(CIO).config { - install(HttpTimeout) { - connectTimeoutMillis = Duration.ofSeconds(10).toMillis() - socketTimeoutMillis = Duration.ofSeconds(10).toMillis() - requestTimeoutMillis = Duration.ofSeconds(300).toMillis() - } - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - coerceInputValues = true - }) - } - } + private val httpClient = + HttpClient(CIO) { + install(HttpTimeout) { + connectTimeoutMillis = Duration.ofSeconds(10).toMillis() + socketTimeoutMillis = Duration.ofSeconds(10).toMillis() + requestTimeoutMillis = Duration.ofSeconds(300).toMillis() + } + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + ) + } + } - lateinit var adminUrl: String - lateinit var publicUrl: String + private val adminUrl = managementPortalProperties.identityServer.adminUrl() + private val publicUrl = managementPortalProperties.identityServer.publicUrl() init { - adminUrl = managementPortalProperties.identityServer.adminUrl() - publicUrl = managementPortalProperties.identityServer.publicUrl() - - log.debug("kratos serverUrl set to ${managementPortalProperties.identityServer.publicUrl()}") - log.debug("kratos serverAdminUrl set to ${managementPortalProperties.identityServer.adminUrl()}") + log.debug("Kratos serverUrl set to $publicUrl") + log.debug("Kratos serverAdminUrl set to $adminUrl") } - /** Save a [User] to the IDP as an identity. Returns the generated [KratosSessionDTO.Identity] */ + /** + * Convert a [User] to a [KratosSessionDTO.Identity] object. + * @param user The object to convert + * @return the newly created DTO object + */ @Throws(IdpException::class) - suspend fun saveAsIdentity(user: User): KratosSessionDTO.Identity? { - val kratosIdentity: KratosSessionDTO.Identity? - - withContext(Dispatchers.IO) { - val identity = createIdentity(user) - - val postRequestBuilder = HttpRequestBuilder().apply { - url("${adminUrl}/admin/identities") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(identity) - } - val response = httpClient.post(postRequestBuilder) - - if (response.status.isSuccess()) { - kratosIdentity = response.body() - log.debug("saved identity for user ${user.login} to IDP as ${kratosIdentity.id}") - } else { - throw IdpException( - "couldn't save Kratos ID to server at " + adminUrl, + private fun createIdentity(user: User): KratosSessionDTO.Identity = + try { + KratosSessionDTO.Identity( + schema_id = "researcher", + traits = KratosSessionDTO.Traits(email = user.email), + metadata_public = + KratosSessionDTO.Metadata( + aud = emptyList(), + sources = emptyList(), + roles = + user.roles.mapNotNull { role -> + role.authority?.name?.let { auth -> + when (role.role?.scope) { + RoleAuthority.Scope.GLOBAL -> auth + RoleAuthority.Scope.ORGANIZATION -> + "${role.organization!!.name}:$auth" + RoleAuthority.Scope.PROJECT -> + "${role.project!!.projectName}:$auth" + null -> null + } + } + }, + authorities = user.authorities, + scope = + Permission.scopes().filter { scope -> + authService.mayBeGranted( + user.roles.mapNotNull { it.role }, + Permission.ofScope(scope) + ) + }, + mp_login = user.login + ) ) + } catch (e: Throwable) { + val message = "Could not convert user ${user.login} to identity" + log.error(message) + throw IdpException(message, e) } - } - - return kratosIdentity - } - /** Update a [User] as to the IDP as an identity. Returns the updated [KratosSessionDTO.Identity] */ + /** + * Save a [User] to the IDP as an identity. Returns the generated [KratosSessionDTO.Identity] + */ @Throws(IdpException::class) - suspend fun updateAssociatedIdentity(user: User): KratosSessionDTO.Identity? { - val kratosIdentity: KratosSessionDTO.Identity? - - user.identity ?: throw IdpException( - "user ${user.login} could not be updated on the IDP. No identity was set", - ) - - withContext(Dispatchers.IO) { - val identity = createIdentity(user) - val response = httpClient.put { - url("${adminUrl}/admin/identities/${user.identity}") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(identity) + suspend fun saveAsIdentity(user: User): KratosSessionDTO.Identity = + withContext(Dispatchers.IO) { + val identity = createIdentity(user) + val response = + httpClient.post { + url("$adminUrl/admin/identities") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(identity) + } + + if (response.status.isSuccess()) { + response.body().also { + log.debug("Saved identity for user ${user.login} to IDP as ${it.id}") + } + } else { + throw IdpException("Couldn't save Kratos ID to server at $adminUrl") + } } + /** + * Update a [User] to the IDP as an identity. Returns the updated [KratosSessionDTO.Identity] + */ + @Throws(IdpException::class) + suspend fun updateAssociatedIdentity(user: User): KratosSessionDTO.Identity = + withContext(Dispatchers.IO) { + val identityId = + user.identity + ?: throw IdpException( + "User ${user.login} could not be updated on the IDP. No identity was set" + ) - if (response.status.isSuccess()) { - kratosIdentity = response.body() - log.debug("Updated identity for user ${user.login} to IDP as ${kratosIdentity.id}") - } else { - throw IdpException( - "Couldn't update identity to server at $adminUrl" - ) - } - } + val identity = createIdentity(user) + val response = + httpClient.put { + url("$adminUrl/admin/identities/$identityId") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(identity) + } - return kratosIdentity - } + if (response.status.isSuccess()) { + response.body().also { + log.debug("Updated identity for user ${user.login} on IDP as ${it.id}") + } + } else { + throw IdpException("Couldn't update identity on server at $adminUrl") + } + } - /** Delete a [User] as to the IDP as an identity. */ + /** + * Update [KratosSessionDTO.Identity] metadata with user roles. Returns the updated + * [KratosSessionDTO.Identity] + */ @Throws(IdpException::class) - suspend fun deleteAssociatedIdentity(userIdentity: String?) { - withContext(Dispatchers.IO) { - userIdentity ?: throw IdpException( - "user with ID ${userIdentity} could not be deleted from the IDP. No identity was set" - ) - - val response = httpClient.delete { - url("${adminUrl}/admin/identities/${userIdentity}") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) + suspend fun updateIdentityMetadataWithRoles( + identity: KratosSessionDTO.Identity, + user: UserDTO + ): KratosSessionDTO.Identity = + withContext(Dispatchers.IO) { + val updatedIdentity = identity.copy(metadata_public = getIdentityMetadata(user)) + + val response = + httpClient.put { + url("$adminUrl/admin/identities/${updatedIdentity.id}") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(updatedIdentity) + } + + if (response.status.isSuccess()) { + response.body().also { + log.debug("Updated identity for ${it.id}") + } + } else { + throw IdpException("Couldn't update identity on server at $adminUrl") + } } + /** Delete a [User] from the IDP as an identity. */ + @Throws(IdpException::class) + suspend fun deleteAssociatedIdentity(userIdentity: String?) = + withContext(Dispatchers.IO) { + val identityId = + userIdentity + ?: throw IdpException( + "User with ID $userIdentity could not be deleted from the IDP. No identity was set" + ) - if (response.status.isSuccess()) { - log.debug("Deleted identity for user ${userIdentity}") - } else { - throw IdpException( - "Couldn't delete identity from server at " + managementPortalProperties.identityServer.serverUrl - ) + val response = + httpClient.delete { + url("$adminUrl/admin/identities/$identityId") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + } + + if (response.status.isSuccess()) { + log.debug("Deleted identity for user $identityId") + } else { + throw IdpException("Couldn't delete identity from server at $adminUrl") + } } - } - } /** - * Convert a [User] to a [KratosSessionDTO.Identity] object. + * Convert a [UserDTO] to a [KratosSessionDTO.Metadata] object. * @param user The object to convert * @return the newly created DTO object */ @Throws(IdpException::class) - private fun createIdentity(user: User): KratosSessionDTO.Identity { - try { - return KratosSessionDTO.Identity( - schema_id = "user", - traits = KratosSessionDTO.Traits(email = user.email), - metadata_public = KratosSessionDTO.Metadata( - aud = emptyList(), - sources = emptyList(), //empty at the time of creation - roles = user.roles.mapNotNull { role: Role -> - val auth = role.authority?.name - when (role.role?.scope) { - RoleAuthority.Scope.GLOBAL -> auth - RoleAuthority.Scope.ORGANIZATION -> role.organization!!.name + ":" + auth - RoleAuthority.Scope.PROJECT -> role.project!!.projectName + ":" + auth - null -> null - } - }.toList(), - authorities = user.authorities, - scope = Permission.scopes().filter { scope -> - val permission = Permission.ofScope(scope) - val auths = user.roles.mapNotNull { it.role } - - return@filter authService.mayBeGranted(auths, permission) - }, - mp_login = user.login + fun getIdentityMetadata(user: UserDTO): KratosSessionDTO.Metadata = + try { + KratosSessionDTO.Metadata( + aud = emptyList(), + sources = emptyList(), + roles = + user.roles.orEmpty().mapNotNull { role -> + role.authorityName?.let { auth -> + when { + role.projectName != null -> "${role.projectName}:$auth" + role.organizationName != null -> + "${role.organizationName}:$auth" + else -> auth + } + } + }, + authorities = user.authorities.orEmpty(), + scope = + Permission.scopes().filter { scope -> + authService.mayBeGranted( + user.roles?.mapNotNull { + RoleAuthority.valueOfAuthority(it.authorityName!!) + } + ?: emptyList(), + Permission.ofScope(scope) + ) + }, + mp_login = user.login ) - ) - } - catch (e: Throwable){ - val message = "could not convert user ${user.login} to identity" - log.error(message) - throw IdpException(message, e) - } - } + } catch (e: Throwable) { + val message = "Could not convert user ${user.login} to identity" + log.error(message) + throw IdpException(message, e) + } /** - * get a recovery link from the identityprovider in the response, which expires in 24 hours. + * Get a recovery link from the identity provider, which expires in 24 hours. * @param user The user for whom the recovery link is requested. * @return The recovery link obtained from the server response. - * @throws IdpException If there is an issue with the identity or if the recovery link cannot be obtained from the server. + * @throws IdpException If there is an issue with the identity or if the recovery link cannot be + * obtained. */ @Throws(IdpException::class) - suspend fun getRecoveryLink(user: User): String { - val recoveryLink: String - - user.identity ?: throw IdpException( - "user ${user.login} could not be recovered on the IDP. No identity was set", - ) - - withContext(Dispatchers.IO) { - val response = httpClient.post { - url("${adminUrl}/admin/recovery/link") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody( - mapOf( - "expires_in" to "24h", - "identity_id" to user.identity - ) - ) - } + suspend fun getRecoveryLink(user: User): String = + withContext(Dispatchers.IO) { + val identityId = + user.identity + ?: throw IdpException( + "User ${user.login} could not be recovered on the IDP. No identity was set" + ) - if (response.status.isSuccess()) { - recoveryLink = response.body>()["recovery_link"]!! - log.debug("recovery link for user ${user.login} is $recoveryLink") - } else { - throw IdpException( - "couldn't get recovery link from server at $adminUrl" - ) - } - } + val response = + httpClient.post { + url("$adminUrl/admin/recovery/link") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(mapOf("expires_in" to "24h", "identity_id" to identityId)) + } - return recoveryLink - } + if (response.status.isSuccess()) { + response.body>()["recovery_link"]!!.also { + log.debug("Recovery link for user ${user.login} is $it") + } + } else { + throw IdpException("Couldn't get recovery link from server at $adminUrl") + } + } companion object { private val log = LoggerFactory.getLogger(IdentityService::class.java) diff --git a/src/main/java/org/radarbase/management/service/SubjectService.kt b/src/main/java/org/radarbase/management/service/SubjectService.kt index ad281e4c4..056e1d496 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.kt +++ b/src/main/java/org/radarbase/management/service/SubjectService.kt @@ -1,5 +1,13 @@ package org.radarbase.management.service +import java.net.MalformedURLException +import java.net.URL +import java.time.ZonedDateTime +import java.util.* +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.Predicate +import javax.annotation.Nonnull import org.hibernate.envers.query.AuditEntity import org.radarbase.auth.authorization.EntityDetails import org.radarbase.auth.authorization.Permission @@ -40,33 +48,23 @@ import org.springframework.data.domain.Page import org.springframework.data.history.Revision import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.net.MalformedURLException -import java.net.URL -import java.time.ZonedDateTime -import java.util.* -import java.util.function.Consumer -import java.util.function.Function -import java.util.function.Predicate -import javax.annotation.Nonnull -/** - * Created by nivethika on 26-5-17. - */ +/** Created by nivethika on 26-5-17. */ @Service @Transactional class SubjectService( - @Autowired private val subjectMapper: SubjectMapper, - @Autowired private val projectMapper: ProjectMapper, - @Autowired private val subjectRepository: SubjectRepository, - @Autowired private val sourceRepository: SourceRepository, - @Autowired private val sourceMapper: SourceMapper, - @Autowired private val roleRepository: RoleRepository, - @Autowired private val groupRepository: GroupRepository, - @Autowired private val revisionService: RevisionService, - @Autowired private val managementPortalProperties: ManagementPortalProperties, - @Autowired private val passwordService: PasswordService, - @Autowired private val authorityRepository: AuthorityRepository, - @Autowired private val authService: AuthService + @Autowired private val subjectMapper: SubjectMapper, + @Autowired private val projectMapper: ProjectMapper, + @Autowired private val subjectRepository: SubjectRepository, + @Autowired private val sourceRepository: SourceRepository, + @Autowired private val sourceMapper: SourceMapper, + @Autowired private val roleRepository: RoleRepository, + @Autowired private val groupRepository: GroupRepository, + @Autowired private val revisionService: RevisionService, + @Autowired private val managementPortalProperties: ManagementPortalProperties, + @Autowired private val passwordService: PasswordService, + @Autowired private val authorityRepository: AuthorityRepository, + @Autowired private val authService: AuthService ) { /** @@ -76,9 +74,9 @@ class SubjectService( * @return the newly created subject */ @Transactional - fun createSubject(subjectDto: SubjectDTO): SubjectDTO? { + fun createSubject(subjectDto: SubjectDTO, activated: Boolean? = true): SubjectDTO? { val subject = subjectMapper.subjectDTOToSubject(subjectDto) ?: throw NullPointerException() - //assign roles + // assign roles val user = subject.user val project = projectMapper.projectDTOToProject(subjectDto.project) val projectParticipantRole = getProjectParticipantRole(project, RoleAuthority.PARTICIPANT) @@ -95,13 +93,15 @@ class SubjectService( user.langKey = "en" user.resetDate = ZonedDateTime.now() // default subject is activated. - user.activated = true - //set if any devices are set as assigned + user.activated = activated!! + // set if any devices are set as assigned if (subject.sources.isNotEmpty()) { - subject.sources.forEach(Consumer { s: Source -> - s.assigned = true - s.subject(subject) - }) + subject.sources.forEach( + Consumer { s: Source -> + s.assigned = true + s.subject(subject) + } + ) } if (subject.enrollmentDate == null) { subject.enrollmentDate = ZonedDateTime.now() @@ -110,14 +110,29 @@ class SubjectService( return subjectMapper.subjectToSubjectReducedProjectDTO(subjectRepository.save(subject)) } + fun createSubject(id: String, projectDto: ProjectDTO): SubjectDTO? { + return createSubject( + SubjectDTO().apply { + login = id + project = projectDto + }, + activated = false + ) + } + private fun getSubjectGroup(project: Project?, groupName: String?): Group? { return if (project == null || groupName == null) { null - } else groupRepository.findByProjectIdAndName(project.id, groupName) ?: throw BadRequestException( - "Group " + groupName + " does not exist in project " + project.projectName, - EntityName.GROUP, - ErrorConstants.ERR_GROUP_NOT_FOUND - ) + } else + groupRepository.findByProjectIdAndName(project.id, groupName) + ?: throw BadRequestException( + "Group " + + groupName + + " does not exist in project " + + project.projectName, + EntityName.GROUP, + ErrorConstants.ERR_GROUP_NOT_FOUND + ) } /** @@ -128,14 +143,13 @@ class SubjectService( * @throws java.util.NoSuchElementException if the authority name is not in the database */ private fun getProjectParticipantRole(project: Project?, authority: RoleAuthority): Role { - val ans: Role? = roleRepository.findOneByProjectIdAndAuthorityName( - project?.id, authority.authority - ) + val ans: Role? = + roleRepository.findOneByProjectIdAndAuthorityName(project?.id, authority.authority) return if (ans == null) { val subjectRole = Role() - val auth: Authority = authorityRepository.findByAuthorityName( - authority.authority - ) ?: authorityRepository.save(Authority(authority)) + val auth: Authority = + authorityRepository.findByAuthorityName(authority.authority) + ?: authorityRepository.save(Authority(authority)) subjectRole.authority = auth subjectRole.project = project @@ -157,52 +171,69 @@ class SubjectService( } val subjectFromDb = ensureSubject(newSubjectDto) val sourcesToUpdate = subjectFromDb.sources - //set only the devices assigned to a subject as assigned + // set only the devices assigned to a subject as assigned subjectMapper.safeUpdateSubjectFromDTO(newSubjectDto, subjectFromDb) sourcesToUpdate.addAll(subjectFromDb.sources) - subjectFromDb.sources.forEach(Consumer { s: Source -> - s.subject(subjectFromDb).assigned = true }) + subjectFromDb.sources.forEach( + Consumer { s: Source -> s.subject(subjectFromDb).assigned = true } + ) sourceRepository.saveAll(sourcesToUpdate) // update participant role subjectFromDb.user!!.roles = updateParticipantRoles(subjectFromDb, newSubjectDto) // Set group - subjectFromDb.group = getSubjectGroup( - subjectFromDb.activeProject, newSubjectDto.group - ) + subjectFromDb.group = getSubjectGroup(subjectFromDb.activeProject, newSubjectDto.group) return subjectMapper.subjectToSubjectReducedProjectDTO( - subjectRepository.save(subjectFromDb) + subjectRepository.save(subjectFromDb) ) } + fun activateSubject(login: String): SubjectDTO? { + val subject = findOneByLogin(login) + subject.user!!.activated = true + return subjectMapper.subjectToSubjectReducedProjectDTO(subjectRepository.save(subject)) + } + private fun updateParticipantRoles(subject: Subject, subjectDto: SubjectDTO): MutableSet { if (subjectDto.project == null || subjectDto.project!!.projectName == null) { return subject.user!!.roles } - val existingRoles = subject.user!!.roles.map { - // make participant inactive in projects that do not match the new project - if (it.authority!!.name == RoleAuthority.PARTICIPANT.authority && it.project!!.projectName != subjectDto.project!!.projectName) { - return@map getProjectParticipantRole(it.project, RoleAuthority.INACTIVE_PARTICIPANT) - } else { - // do not modify other roles. - return@map it - } - }.toMutableSet() + val existingRoles = + subject.user!! + .roles + .map { + // make participant inactive in projects that do not match the new + // project + if (it.authority!!.name == RoleAuthority.PARTICIPANT.authority && + it.project!!.projectName != + subjectDto.project!!.projectName + ) { + return@map getProjectParticipantRole( + it.project, + RoleAuthority.INACTIVE_PARTICIPANT + ) + } else { + // do not modify other roles. + return@map it + } + } + .toMutableSet() // Ensure that given project is present val newProjectRole = - getProjectParticipantRole(projectMapper.projectDTOToProject(subjectDto.project), RoleAuthority.PARTICIPANT) + getProjectParticipantRole( + projectMapper.projectDTOToProject(subjectDto.project), + RoleAuthority.PARTICIPANT + ) existingRoles.add(newProjectRole) return existingRoles - } /** * Discontinue the given subject. * - * - * A discontinued subject is not deleted from the database, but will be prevented from - * logging into the system, sending data, or otherwise interacting with the system. + * A discontinued subject is not deleted from the database, but will be prevented from logging + * into the system, sending data, or otherwise interacting with the system. * * @param subjectDto the subject to discontinue * @return the discontinued subject @@ -222,13 +253,12 @@ class SubjectService( private fun ensureSubject(subjectDto: SubjectDTO): Subject { return try { subjectDto.id?.let { subjectRepository.findById(it).get() } - ?: throw Exception("invalid subject ${subjectDto.login}: No ID") - } - catch(e: Throwable) { + ?: throw Exception("invalid subject ${subjectDto.login}: No ID") + } catch (e: Throwable) { throw NotFoundException( - "Subject with ID " + subjectDto.id + " not found.", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND + "Subject with ID " + subjectDto.id + " not found.", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND ) } } @@ -240,12 +270,14 @@ class SubjectService( * @param subject The subject for which to unassign all sources */ private fun unassignAllSources(subject: Subject) { - subject.sources.forEach(Consumer { source: Source -> - source.assigned = false - source.subject = null - source.deleted = true - sourceRepository.save(source) - }) + subject.sources.forEach( + Consumer { source: Source -> + source.assigned = false + source.subject = null + source.deleted = true + sourceRepository.save(source) + } + ) subject.sources.clear() } @@ -257,20 +289,28 @@ class SubjectService( */ @Transactional fun assignOrUpdateSource( - subject: Subject, sourceType: SourceType, project: Project?, sourceRegistrationDto: MinimalSourceDetailsDTO + subject: Subject, + sourceType: SourceType, + project: Project?, + sourceRegistrationDto: MinimalSourceDetailsDTO ): MinimalSourceDetailsDTO { val assignedSource: Source if (sourceRegistrationDto.sourceId != null) { // update meta-data and source-name for existing sources assignedSource = updateSourceAssignedSubject(subject, sourceRegistrationDto) } else if (sourceType.canRegisterDynamically!!) { - val sources = subjectRepository.findSubjectSourcesBySourceType( - subject.user!!.login, sourceType.producer, sourceType.model, sourceType.catalogVersion - ) + val sources = + subjectRepository.findSubjectSourcesBySourceType( + subject.user!!.login, + sourceType.producer, + sourceType.model, + sourceType.catalogVersion + ) // create a source and register metadata // we allow only one source of a source-type per subject if (sources.isNullOrEmpty()) { - var source = Source(sourceType).project(project).sourceType(sourceType).subject(subject) + var source = + Source(sourceType).project(project).sourceType(sourceType).subject(subject) source.assigned = true source.attributes += sourceRegistrationDto.attributes // if source name is provided update source name @@ -281,10 +321,11 @@ class SubjectService( // make sure there is no source available on the same name. if (sourceRepository.findOneBySourceName(source.sourceName!!) != null) { throw ConflictException( - "SourceName already in use. Cannot create a " + "source with existing source-name ", - EntityName.SUBJECT, - ErrorConstants.ERR_SOURCE_NAME_EXISTS, - Collections.singletonMap("source-name", source.sourceName) + "SourceName already in use. Cannot create a " + + "source with existing source-name ", + EntityName.SUBJECT, + ErrorConstants.ERR_SOURCE_NAME_EXISTS, + Collections.singletonMap("source-name", source.sourceName) ) } source = sourceRepository.save(source) @@ -292,19 +333,20 @@ class SubjectService( subject.sources.add(source) } else { throw ConflictException( - "A Source of SourceType with the specified producer, model and version" + " was already registered for subject login", - EntityName.SUBJECT, - ErrorConstants.ERR_SOURCE_TYPE_EXISTS, - sourceTypeAttributes(sourceType, subject) + "A Source of SourceType with the specified producer, model and version" + + " was already registered for subject login", + EntityName.SUBJECT, + ErrorConstants.ERR_SOURCE_TYPE_EXISTS, + sourceTypeAttributes(sourceType, subject) ) } } else { // new source since sourceId == null, but canRegisterDynamically == false throw BadRequestException( - "The source type is not eligible for dynamic " + "registration", - EntityName.SOURCE_TYPE, - "error.InvalidDynamicSourceRegistration", - sourceTypeAttributes(sourceType, subject) + "The source type is not eligible for dynamic " + "registration", + EntityName.SOURCE_TYPE, + "error.InvalidDynamicSourceRegistration", + sourceTypeAttributes(sourceType, subject) ) } subjectRepository.save(subject) @@ -319,21 +361,24 @@ class SubjectService( * @return Updated [Source] instance. */ private fun updateSourceAssignedSubject( - subject: Subject, sourceRegistrationDto: MinimalSourceDetailsDTO + subject: Subject, + sourceRegistrationDto: MinimalSourceDetailsDTO ): Source { // for manually registered devices only add meta-data - val source = subjectRepository.findSubjectSourcesBySourceId( - subject.user?.login, sourceRegistrationDto.sourceId - ) + val source = + subjectRepository.findSubjectSourcesBySourceId( + subject.user?.login, + sourceRegistrationDto.sourceId + ) if (source == null) { val errorParams: MutableMap = HashMap() errorParams["sourceId"] = sourceRegistrationDto.sourceId.toString() errorParams["subject-login"] = subject.user?.login throw NotFoundException( - "No source with source-id to assigned to the subject with subject-login", - EntityName.SUBJECT, - ErrorConstants.ERR_SOURCE_NOT_FOUND, - errorParams + "No source with source-id to assigned to the subject with subject-login", + EntityName.SUBJECT, + ErrorConstants.ERR_SOURCE_NOT_FOUND, + errorParams ) } @@ -353,7 +398,10 @@ class SubjectService( */ fun getSources(subject: Subject): List { val sources = subjectRepository.findSourcesBySubjectLogin(subject.user?.login) - if (sources.isEmpty()) throw org.webjars.NotFoundException("Could not find sources for user ${subject.user}") + if (sources.isEmpty()) + throw org.webjars.NotFoundException( + "Could not find sources for user ${subject.user}" + ) return sourceMapper.sourcesToMinimalSourceDetailsDTOs(sources) } @@ -367,12 +415,13 @@ class SubjectService( unassignAllSources(subject) subjectRepository.delete(subject) log.debug("Deleted Subject: {}", subject) - } ?: throw NotFoundException( - "subject not found for given login.", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) - ) + } + ?: throw NotFoundException( + "subject not found for given login.", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login) + ) } /** @@ -384,10 +433,14 @@ class SubjectService( fun findSubjectSourcesFromRevisions(subject: Subject): List? { val revisions = subject.id?.let { subjectRepository.findRevisions(it) } // collect distinct sources in a set - val sources: List? = revisions?.content?.flatMap { p: Revision -> p.entity.sources } - ?.distinctBy { obj: Source -> obj.sourceId } - - return sources?.map { p: Source -> sourceMapper.sourceToMinimalSourceDetailsDTO(p) }?.toList() + val sources: List? = + revisions?.content + ?.flatMap { p: Revision -> p.entity.sources } + ?.distinctBy { obj: Source -> obj.sourceId } + + return sources + ?.map { p: Source -> sourceMapper.sourceToMinimalSourceDetailsDTO(p) } + ?.toList() } /** @@ -396,25 +449,30 @@ class SubjectService( * @param login the login of the subject * @param revision the revision number * @return the subject at the given revision - * @throws NotFoundException if there was no subject with the given login at the given - * revision number + * @throws NotFoundException if there was no subject with the given login at the given revision + * number */ @Throws(NotFoundException::class, NotAuthorizedException::class) fun findRevision(login: String?, revision: Int?): SubjectDTO { // first get latest known version of the subject, if it's deleted we can't load the entity // directly by e.g. findOneByLogin val latest = getLatestRevision(login) - authService.checkPermission(Permission.SUBJECT_READ, { e: EntityDetails -> - e.project(latest.project?.projectName).subject(latest.login) - }) + authService.checkPermission( + Permission.SUBJECT_READ, + { e: EntityDetails -> e.project(latest.project?.projectName).subject(latest.login) } + ) return revisionService.findRevision( - revision, latest.id, Subject::class.java, subjectMapper::subjectToSubjectReducedProjectDTO - ) ?: throw NotFoundException( - "subject not found for given login and revision.", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) + revision, + latest.id, + Subject::class.java, + subjectMapper::subjectToSubjectReducedProjectDTO ) + ?: throw NotFoundException( + "subject not found for given login and revision.", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login) + ) } /** @@ -426,26 +484,33 @@ class SubjectService( */ @Throws(NotFoundException::class) fun getLatestRevision(login: String?): SubjectDTO { - val user = revisionService.getLatestRevisionForEntity( - User::class.java, listOf(AuditEntity.property("login").eq(login)) - ).orElseThrow { - NotFoundException( - "Subject latest revision not found " + "for login", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) - ) - } as UserDTO + val user = + revisionService.getLatestRevisionForEntity( + User::class.java, + listOf(AuditEntity.property("login").eq(login)) + ) + .orElseThrow { + NotFoundException( + "Subject latest revision not found " + "for login", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login) + ) + } as + UserDTO return revisionService.getLatestRevisionForEntity( - Subject::class.java, listOf(AuditEntity.property("user").eq(user)) - ).orElseThrow { - NotFoundException( - "Subject latest revision not found " + "for login", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) - ) - } as SubjectDTO + Subject::class.java, + listOf(AuditEntity.property("user").eq(user)) + ) + .orElseThrow { + NotFoundException( + "Subject latest revision not found " + "for login", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login) + ) + } as + SubjectDTO } /** @@ -456,9 +521,12 @@ class SubjectService( @Nonnull fun findOneByLogin(login: String?): Subject { val subject = subjectRepository.findOneWithEagerBySubjectLogin(login) - return subject ?: throw NotFoundException( - "Subject not found with login", EntityName.SUBJECT, ErrorConstants.ERR_SUBJECT_NOT_FOUND - ) + return subject + ?: throw NotFoundException( + "Subject not found with login", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND + ) } /** @@ -471,18 +539,14 @@ class SubjectService( // but the page should always be zero // since the lastLoadedId param defines the offset // within the query specification - return subjectRepository.findAll( - SubjectSpecification(criteria), criteria.pageable - ) + return subjectRepository.findAll(SubjectSpecification(criteria), criteria.pageable) } /** * Gets relevant privacy-policy-url for this subject. * - * * If the active project of the subject has a valid privacy-policy-url returns that url. - * Otherwise, it loads the default URL from ManagementPortal configurations that is - * general. + * Otherwise, it loads the default URL from ManagementPortal configurations that is general. * * @param subject to get relevant policy url * @return URL of privacy policy for this token @@ -490,8 +554,9 @@ class SubjectService( fun getPrivacyPolicyUrl(subject: Subject): URL { // load default url from config - val policyUrl: String = subject.activeProject?.attributes?.get(ProjectDTO.PRIVACY_POLICY_URL) - ?: managementPortalProperties.common.privacyPolicyUrl + val policyUrl: String = + subject.activeProject?.attributes?.get(ProjectDTO.PRIVACY_POLICY_URL) + ?: managementPortalProperties.common.privacyPolicyUrl return try { URL(policyUrl) } catch (e: MalformedURLException) { @@ -499,10 +564,11 @@ class SubjectService( params["url"] = policyUrl params["message"] = e.message throw InvalidStateException( - "No valid privacy-policy Url configured. Please " + "verify your project's privacy-policy url and/or general url config", - EntityName.OAUTH_CLIENT, - ErrorConstants.ERR_NO_VALID_PRIVACY_POLICY_URL_CONFIGURED, - params + "No valid privacy-policy Url configured. Please " + + "verify your project's privacy-policy url and/or general url config", + EntityName.OAUTH_CLIENT, + ErrorConstants.ERR_NO_VALID_PRIVACY_POLICY_URL_CONFIGURED, + params ) } } @@ -510,7 +576,8 @@ class SubjectService( companion object { private val log = LoggerFactory.getLogger(SubjectService::class.java) private fun sourceTypeAttributes( - sourceType: SourceType, subject: Subject + sourceType: SourceType, + subject: Subject ): Map { val errorParams: MutableMap = HashMap() errorParams["producer"] = sourceType.producer diff --git a/src/main/java/org/radarbase/management/service/dto/KratosSubjectWebhookDTO.kt b/src/main/java/org/radarbase/management/service/dto/KratosSubjectWebhookDTO.kt new file mode 100644 index 000000000..23ad51849 --- /dev/null +++ b/src/main/java/org/radarbase/management/service/dto/KratosSubjectWebhookDTO.kt @@ -0,0 +1,28 @@ +package org.radarbase.management.service.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import java.io.Serializable +import java.time.LocalDate +import java.time.ZonedDateTime +import java.util.* +import org.radarbase.auth.kratos.KratosSessionDTO + +/** + * A DTO for the Subject entity. + */ +class KratosSubjectWebhookDTO : Serializable { + @JsonInclude(JsonInclude.Include.NON_NULL) + var identity: KratosSessionDTO.Identity? = null + + @JsonInclude(JsonInclude.Include.NON_NULL) + var payload: Map? = null + + @JsonInclude(JsonInclude.Include.NON_NULL) + var cookies: Map? = null + + companion object { + private const val serialVersionUID = 1L + } +} \ No newline at end of file diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt new file mode 100644 index 000000000..b7e39a858 --- /dev/null +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -0,0 +1,114 @@ +package org.radarbase.management.web.rest + +import io.micrometer.core.annotation.Timed +import java.net.URISyntaxException +import org.radarbase.auth.kratos.KratosSessionDTO +import org.radarbase.auth.kratos.SessionService +import org.radarbase.management.config.ManagementPortalProperties +import org.radarbase.management.repository.SubjectRepository +import org.radarbase.management.security.NotAuthorizedException +import org.radarbase.management.service.* +import org.radarbase.management.service.dto.KratosSubjectWebhookDTO +import org.radarbase.management.service.mapper.SubjectMapper +import org.radarbase.management.web.rest.errors.EntityName +import org.radarbase.management.web.rest.errors.NotFoundException +import org.radarbase.management.web.rest.util.HeaderUtil +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/kratos") +class KratosEndpoint +@Autowired +constructor( + @Autowired private val subjectService: SubjectService, + @Autowired private val subjectRepository: SubjectRepository, + @Autowired private val projectService: ProjectService, + @Autowired private val userService: UserService, + @Autowired private val identityService: IdentityService, + @Autowired private val managementPortalProperties: ManagementPortalProperties, + @Autowired private val subjectMapper: SubjectMapper, +) { + private var sessionService: SessionService = + SessionService(managementPortalProperties.identityServer.publicUrl()) + + /** + * POST /subjects : Create a new subject. + * + * @param subjectDto the subjectDto to create + * @return the ResponseEntity with status 201 (Created) and with body the new subjectDto, or + * with status 400 (Bad Request) if the subject has already an ID + * @throws URISyntaxException if the Location URI syntax is incorrect + */ + @PostMapping("/subjects") + @Timed + @Throws(URISyntaxException::class, NotAuthorizedException::class) + suspend fun createSubject( + @RequestBody webhookDTO: KratosSubjectWebhookDTO, + ): ResponseEntity { + val kratosIdentity = + webhookDTO.identity ?: throw IllegalArgumentException("Identity is required") + + if (!kratosIdentity.schema_id.equals(KRATOS_SUBJECT_SCHEMA)) + throw IllegalArgumentException("Cannot create non-subject users") + + val id = kratosIdentity.id ?: throw IllegalArgumentException("Identity ID is required") + val projectId = + webhookDTO.payload?.get("project_id") + ?: throw NotAuthorizedException("Cannot create subject without project") + val projectDto = + projectService.findOneByName(projectId) + ?: throw NotFoundException( + "Project not found: $projectId", + EntityName.PROJECT, + "projectNotFound" + ) + val subjectDto = + subjectService.createSubject(id, projectDto) + ?: throw IllegalStateException("Failed to create subject for ID: $id") + val user = + userService.getUserWithAuthoritiesByLogin(subjectDto.login!!) + ?: throw NotFoundException( + "User not found with login: ${subjectDto.login}", + EntityName.USER, + "userNotFound" + ) + + identityService.updateIdentityMetadataWithRoles(kratosIdentity, user) + return ResponseEntity.created(ResourceUriService.getUri(subjectDto)) + .headers(HeaderUtil.createEntityCreationAlert(EntityName.SUBJECT, id)) + .build() + } + + @PostMapping("/subjects/activate") + @Timed + @Throws(URISyntaxException::class, NotAuthorizedException::class) + suspend fun activateSubject( + @RequestBody webhookDTO: KratosSubjectWebhookDTO, + ): ResponseEntity { + val id = webhookDTO.identity?.id ?: throw IllegalArgumentException("Subject ID is required") + val token = + webhookDTO.cookies?.get("ory_kratos_session") + ?: throw IllegalArgumentException("Session token is required") + val kratosIdentity = sessionService.getSession(token).identity + if (!hasPermission(kratosIdentity, id)) { + throw NotAuthorizedException("Not authorized to activate subject") + } + subjectService.activateSubject(id) + return ResponseEntity.ok() + .headers(HeaderUtil.createEntityUpdateAlert(EntityName.SUBJECT, id)) + .build() + } + + private fun hasPermission( + kratosIdentity: KratosSessionDTO.Identity, + identityId: String?, + ): Boolean = kratosIdentity.id == identityId + + companion object { + private val logger = LoggerFactory.getLogger(KratosEndpoint::class.java) + private val KRATOS_SUBJECT_SCHEMA = "subject" + } +} \ No newline at end of file From d1588ba17a1ffd076454e55339fd9ea7d4e9c079 Mon Sep 17 00:00:00 2001 From: Pauline Date: Wed, 28 Aug 2024 16:01:26 +0100 Subject: [PATCH 02/28] Separate out subject, researcher, admin identities --- ...a.user.json => identity.schema.admin.json} | 4 +- .../identity.schema.researcher.json | 37 +++++++++++++++++++ .../identities/identity.schema.subject.json | 37 +++++++++++++++++++ src/main/docker/etc/config/kratos/kratos.yml | 10 +++-- 4 files changed, 83 insertions(+), 5 deletions(-) rename src/main/docker/etc/config/kratos/identities/{identity.schema.user.json => identity.schema.admin.json} (95%) create mode 100644 src/main/docker/etc/config/kratos/identities/identity.schema.researcher.json create mode 100644 src/main/docker/etc/config/kratos/identities/identity.schema.subject.json diff --git a/src/main/docker/etc/config/kratos/identities/identity.schema.user.json b/src/main/docker/etc/config/kratos/identities/identity.schema.admin.json similarity index 95% rename from src/main/docker/etc/config/kratos/identities/identity.schema.user.json rename to src/main/docker/etc/config/kratos/identities/identity.schema.admin.json index 060b3fa3c..b127b798c 100644 --- a/src/main/docker/etc/config/kratos/identities/identity.schema.user.json +++ b/src/main/docker/etc/config/kratos/identities/identity.schema.admin.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "user", - "title": "user", + "$id": "admin", + "title": "admin", "type": "object", "properties": { "traits": { diff --git a/src/main/docker/etc/config/kratos/identities/identity.schema.researcher.json b/src/main/docker/etc/config/kratos/identities/identity.schema.researcher.json new file mode 100644 index 000000000..e8d1d0990 --- /dev/null +++ b/src/main/docker/etc/config/kratos/identities/identity.schema.researcher.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "researcher", + "title": "researcher", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 5, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "totp": { + "account_name": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + } + }, + "required": ["email"] + } + }, + "additionalProperties": false +} diff --git a/src/main/docker/etc/config/kratos/identities/identity.schema.subject.json b/src/main/docker/etc/config/kratos/identities/identity.schema.subject.json new file mode 100644 index 000000000..b87c21988 --- /dev/null +++ b/src/main/docker/etc/config/kratos/identities/identity.schema.subject.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "subject", + "title": "subject", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 5, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "totp": { + "account_name": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + } + }, + "required": ["email"] + } + }, + "additionalProperties": false +} diff --git a/src/main/docker/etc/config/kratos/kratos.yml b/src/main/docker/etc/config/kratos/kratos.yml index d194bcfc3..36a824e98 100644 --- a/src/main/docker/etc/config/kratos/kratos.yml +++ b/src/main/docker/etc/config/kratos/kratos.yml @@ -78,10 +78,14 @@ hashers: key_length: 16 identity: - default_schema_id: user + default_schema_id: subject schemas: - - id: user - url: file:///etc/config/kratos/identities/identity.schema.user.json + - id: subject + url: file:///etc/config/kratos/identities/identity.schema.subject.json + - id: researcher + url: file:///etc/config/kratos/identities/identity.schema.researcher.json + - id: admin + url: file:///etc/config/kratos/identities/identity.schema.admin.json courier: smtp: From fc283147ce2e02bb914b98b89ae8c3b6b0d5a22e Mon Sep 17 00:00:00 2001 From: Pauline Date: Wed, 28 Aug 2024 16:35:51 +0100 Subject: [PATCH 03/28] Fix Kratos Identity class --- .../src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index 805c71a68..b89b38ae7 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -45,7 +45,7 @@ class KratosSessionDTO( ) @Serializable - class Identity( + data class Identity( var id: String? = null, val schema_id: String? = null, val schema_url: String? = null, From b81e0fd61898d730f3e7d87ad30f63e35a692e20 Mon Sep 17 00:00:00 2001 From: Pauline Date: Wed, 28 Aug 2024 16:48:36 +0100 Subject: [PATCH 04/28] Fix formatting --- .../management/service/IdentityService.kt | 316 +++++++++--------- 1 file changed, 158 insertions(+), 158 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index e0b0bd202..4ad5c41dd 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -8,7 +8,6 @@ import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* -import java.time.Duration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -23,17 +22,18 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Duration /** Service class for managing identities. */ @Service @Transactional class IdentityService -@Autowired -constructor( + @Autowired + constructor( private val managementPortalProperties: ManagementPortalProperties, - private val authService: AuthService -) { - private val httpClient = + private val authService: AuthService, + ) { + private val httpClient = HttpClient(CIO) { install(HttpTimeout) { connectTimeoutMillis = Duration.ofSeconds(10).toMillis() @@ -42,60 +42,60 @@ constructor( } install(ContentNegotiation) { json( - Json { - ignoreUnknownKeys = true - coerceInputValues = true - } + Json { + ignoreUnknownKeys = true + coerceInputValues = true + }, ) } } - private val adminUrl = managementPortalProperties.identityServer.adminUrl() - private val publicUrl = managementPortalProperties.identityServer.publicUrl() + private val adminUrl = managementPortalProperties.identityServer.adminUrl() + private val publicUrl = managementPortalProperties.identityServer.publicUrl() - init { - log.debug("Kratos serverUrl set to $publicUrl") - log.debug("Kratos serverAdminUrl set to $adminUrl") - } + init { + log.debug("Kratos serverUrl set to $publicUrl") + log.debug("Kratos serverAdminUrl set to $adminUrl") + } - /** - * Convert a [User] to a [KratosSessionDTO.Identity] object. - * @param user The object to convert - * @return the newly created DTO object - */ - @Throws(IdpException::class) - private fun createIdentity(user: User): KratosSessionDTO.Identity = + /** + * Convert a [User] to a [KratosSessionDTO.Identity] object. + * @param user The object to convert + * @return the newly created DTO object + */ + @Throws(IdpException::class) + private fun createIdentity(user: User): KratosSessionDTO.Identity = try { KratosSessionDTO.Identity( - schema_id = "researcher", - traits = KratosSessionDTO.Traits(email = user.email), - metadata_public = - KratosSessionDTO.Metadata( - aud = emptyList(), - sources = emptyList(), - roles = - user.roles.mapNotNull { role -> - role.authority?.name?.let { auth -> - when (role.role?.scope) { - RoleAuthority.Scope.GLOBAL -> auth - RoleAuthority.Scope.ORGANIZATION -> - "${role.organization!!.name}:$auth" - RoleAuthority.Scope.PROJECT -> - "${role.project!!.projectName}:$auth" - null -> null - } - } - }, - authorities = user.authorities, - scope = - Permission.scopes().filter { scope -> - authService.mayBeGranted( - user.roles.mapNotNull { it.role }, - Permission.ofScope(scope) - ) - }, - mp_login = user.login - ) + schema_id = "researcher", + traits = KratosSessionDTO.Traits(email = user.email), + metadata_public = + KratosSessionDTO.Metadata( + aud = emptyList(), + sources = emptyList(), + roles = + user.roles.mapNotNull { role -> + role.authority?.name?.let { auth -> + when (role.role?.scope) { + RoleAuthority.Scope.GLOBAL -> auth + RoleAuthority.Scope.ORGANIZATION -> + "${role.organization!!.name}:$auth" + RoleAuthority.Scope.PROJECT -> + "${role.project!!.projectName}:$auth" + null -> null + } + } + }, + authorities = user.authorities, + scope = + Permission.scopes().filter { scope -> + authService.mayBeGranted( + user.roles.mapNotNull { it.role }, + Permission.ofScope(scope), + ) + }, + mp_login = user.login, + ), ) } catch (e: Throwable) { val message = "Could not convert user ${user.login} to identity" @@ -103,20 +103,20 @@ constructor( throw IdpException(message, e) } - /** - * Save a [User] to the IDP as an identity. Returns the generated [KratosSessionDTO.Identity] - */ - @Throws(IdpException::class) - suspend fun saveAsIdentity(user: User): KratosSessionDTO.Identity = + /** + * Save a [User] to the IDP as an identity. Returns the generated [KratosSessionDTO.Identity] + */ + @Throws(IdpException::class) + suspend fun saveAsIdentity(user: User): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { val identity = createIdentity(user) val response = - httpClient.post { - url("$adminUrl/admin/identities") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(identity) - } + httpClient.post { + url("$adminUrl/admin/identities") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(identity) + } if (response.status.isSuccess()) { response.body().also { @@ -127,26 +127,26 @@ constructor( } } - /** - * Update a [User] to the IDP as an identity. Returns the updated [KratosSessionDTO.Identity] - */ - @Throws(IdpException::class) - suspend fun updateAssociatedIdentity(user: User): KratosSessionDTO.Identity = + /** + * Update a [User] to the IDP as an identity. Returns the updated [KratosSessionDTO.Identity] + */ + @Throws(IdpException::class) + suspend fun updateAssociatedIdentity(user: User): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { val identityId = - user.identity - ?: throw IdpException( - "User ${user.login} could not be updated on the IDP. No identity was set" - ) + user.identity + ?: throw IdpException( + "User ${user.login} could not be updated on the IDP. No identity was set", + ) val identity = createIdentity(user) val response = - httpClient.put { - url("$adminUrl/admin/identities/$identityId") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(identity) - } + httpClient.put { + url("$adminUrl/admin/identities/$identityId") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(identity) + } if (response.status.isSuccess()) { response.body().also { @@ -157,25 +157,25 @@ constructor( } } - /** - * Update [KratosSessionDTO.Identity] metadata with user roles. Returns the updated - * [KratosSessionDTO.Identity] - */ - @Throws(IdpException::class) - suspend fun updateIdentityMetadataWithRoles( + /** + * Update [KratosSessionDTO.Identity] metadata with user roles. Returns the updated + * [KratosSessionDTO.Identity] + */ + @Throws(IdpException::class) + suspend fun updateIdentityMetadataWithRoles( identity: KratosSessionDTO.Identity, - user: UserDTO - ): KratosSessionDTO.Identity = + user: UserDTO, + ): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { val updatedIdentity = identity.copy(metadata_public = getIdentityMetadata(user)) val response = - httpClient.put { - url("$adminUrl/admin/identities/${updatedIdentity.id}") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(updatedIdentity) - } + httpClient.put { + url("$adminUrl/admin/identities/${updatedIdentity.id}") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(updatedIdentity) + } if (response.status.isSuccess()) { response.body().also { @@ -186,22 +186,22 @@ constructor( } } - /** Delete a [User] from the IDP as an identity. */ - @Throws(IdpException::class) - suspend fun deleteAssociatedIdentity(userIdentity: String?) = + /** Delete a [User] from the IDP as an identity. */ + @Throws(IdpException::class) + suspend fun deleteAssociatedIdentity(userIdentity: String?) = withContext(Dispatchers.IO) { val identityId = - userIdentity - ?: throw IdpException( - "User with ID $userIdentity could not be deleted from the IDP. No identity was set" - ) + userIdentity + ?: throw IdpException( + "User with ID $userIdentity could not be deleted from the IDP. No identity was set", + ) val response = - httpClient.delete { - url("$adminUrl/admin/identities/$identityId") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - } + httpClient.delete { + url("$adminUrl/admin/identities/$identityId") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + } if (response.status.isSuccess()) { log.debug("Deleted identity for user $identityId") @@ -210,40 +210,40 @@ constructor( } } - /** - * Convert a [UserDTO] to a [KratosSessionDTO.Metadata] object. - * @param user The object to convert - * @return the newly created DTO object - */ - @Throws(IdpException::class) - fun getIdentityMetadata(user: UserDTO): KratosSessionDTO.Metadata = + /** + * Convert a [UserDTO] to a [KratosSessionDTO.Metadata] object. + * @param user The object to convert + * @return the newly created DTO object + */ + @Throws(IdpException::class) + fun getIdentityMetadata(user: UserDTO): KratosSessionDTO.Metadata = try { KratosSessionDTO.Metadata( - aud = emptyList(), - sources = emptyList(), - roles = - user.roles.orEmpty().mapNotNull { role -> - role.authorityName?.let { auth -> - when { - role.projectName != null -> "${role.projectName}:$auth" - role.organizationName != null -> - "${role.organizationName}:$auth" - else -> auth - } - } - }, - authorities = user.authorities.orEmpty(), - scope = - Permission.scopes().filter { scope -> - authService.mayBeGranted( - user.roles?.mapNotNull { - RoleAuthority.valueOfAuthority(it.authorityName!!) - } - ?: emptyList(), - Permission.ofScope(scope) - ) - }, - mp_login = user.login + aud = emptyList(), + sources = emptyList(), + roles = + user.roles.orEmpty().mapNotNull { role -> + role.authorityName?.let { auth -> + when { + role.projectName != null -> "${role.projectName}:$auth" + role.organizationName != null -> + "${role.organizationName}:$auth" + else -> auth + } + } + }, + authorities = user.authorities.orEmpty(), + scope = + Permission.scopes().filter { scope -> + authService.mayBeGranted( + user.roles?.mapNotNull { + RoleAuthority.valueOfAuthority(it.authorityName!!) + } + ?: emptyList(), + Permission.ofScope(scope), + ) + }, + mp_login = user.login, ) } catch (e: Throwable) { val message = "Could not convert user ${user.login} to identity" @@ -251,29 +251,29 @@ constructor( throw IdpException(message, e) } - /** - * Get a recovery link from the identity provider, which expires in 24 hours. - * @param user The user for whom the recovery link is requested. - * @return The recovery link obtained from the server response. - * @throws IdpException If there is an issue with the identity or if the recovery link cannot be - * obtained. - */ - @Throws(IdpException::class) - suspend fun getRecoveryLink(user: User): String = + /** + * Get a recovery link from the identity provider, which expires in 24 hours. + * @param user The user for whom the recovery link is requested. + * @return The recovery link obtained from the server response. + * @throws IdpException If there is an issue with the identity or if the recovery link cannot be + * obtained. + */ + @Throws(IdpException::class) + suspend fun getRecoveryLink(user: User): String = withContext(Dispatchers.IO) { val identityId = - user.identity - ?: throw IdpException( - "User ${user.login} could not be recovered on the IDP. No identity was set" - ) + user.identity + ?: throw IdpException( + "User ${user.login} could not be recovered on the IDP. No identity was set", + ) val response = - httpClient.post { - url("$adminUrl/admin/recovery/link") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(mapOf("expires_in" to "24h", "identity_id" to identityId)) - } + httpClient.post { + url("$adminUrl/admin/recovery/link") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(mapOf("expires_in" to "24h", "identity_id" to identityId)) + } if (response.status.isSuccess()) { response.body>()["recovery_link"]!!.also { @@ -284,7 +284,7 @@ constructor( } } - companion object { - private val log = LoggerFactory.getLogger(IdentityService::class.java) + companion object { + private val log = LoggerFactory.getLogger(IdentityService::class.java) + } } -} From d95a03b7e03998cae89f76624af7a186f1e08323 Mon Sep 17 00:00:00 2001 From: Pauline Date: Mon, 9 Sep 2024 11:25:59 +0100 Subject: [PATCH 05/28] Remove separate user identity property --- .../java/org/radarbase/management/service/UserService.kt | 7 +++---- .../java/org/radarbase/management/service/dto/UserDTO.kt | 3 --- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/UserService.kt b/src/main/java/org/radarbase/management/service/UserService.kt index ade4f6123..c8b91831e 100644 --- a/src/main/java/org/radarbase/management/service/UserService.kt +++ b/src/main/java/org/radarbase/management/service/UserService.kt @@ -169,8 +169,8 @@ class UserService @Autowired constructor( user.activated = true user.roles = getUserRoles(userDto.roles, mutableSetOf()) - try{ - user.identity = identityService.saveAsIdentity(user)?.id + try { + identityService.saveAsIdentity(user) } catch (e: Throwable) { log.warn("could not save user ${user.login} as identity", e) @@ -383,8 +383,7 @@ class UserService @Autowired constructor( // there is no identity for this user, so we create it and save it to the IDP val id = identityService.saveAsIdentity(user) - // then save the identifier and update our database - user.identity = id?.id + return userMapper.userToUserDTO(user) ?: throw Exception("Admin user could not be converted to DTO") } diff --git a/src/main/java/org/radarbase/management/service/dto/UserDTO.kt b/src/main/java/org/radarbase/management/service/dto/UserDTO.kt index 659e230d6..a69e0c533 100644 --- a/src/main/java/org/radarbase/management/service/dto/UserDTO.kt +++ b/src/main/java/org/radarbase/management/service/dto/UserDTO.kt @@ -24,9 +24,6 @@ open class UserDTO { var authorities: Set? = null var accessToken: String? = null - /** Identifier for association with the identity service provider. - * Null if not linked to an external identity. */ - var identity: String? = null override fun toString(): String { return ("UserDTO{" + "login='" + login + '\'' From 54600d3f83a2846dec53324807dcb4ccab923862 Mon Sep 17 00:00:00 2001 From: Pauline Date: Mon, 9 Sep 2024 16:01:13 +0100 Subject: [PATCH 06/28] Remove kratosId from user dialog --- .../user-management-dialog.component.html | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/main/webapp/app/admin/user-management/user-management-dialog.component.html b/src/main/webapp/app/admin/user-management/user-management-dialog.component.html index 2f280b0d0..6cc941d9e 100644 --- a/src/main/webapp/app/admin/user-management/user-management-dialog.component.html +++ b/src/main/webapp/app/admin/user-management/user-management-dialog.component.html @@ -34,20 +34,6 @@ -
- - - -
- - -
-
-
Date: Mon, 9 Sep 2024 16:02:28 +0100 Subject: [PATCH 07/28] Send activation email from Kratos directly --- .../radarbase/auth/kratos/KratosSessionDTO.kt | 6 +++ .../management/service/IdentityService.kt | 43 ++++++++++--------- .../management/service/UserService.kt | 4 +- .../management/web/rest/UserResource.kt | 16 +------ 4 files changed, 32 insertions(+), 37 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index b89b38ae7..17c998416 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -44,6 +44,12 @@ class KratosSessionDTO( val location: String, ) + @Serializable + data class Verification( + val id: String? = null, + val type: String? = null + ) + @Serializable data class Identity( var id: String? = null, diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index 4ad5c41dd..7eee8308d 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -252,35 +252,38 @@ class IdentityService } /** - * Get a recovery link from the identity provider, which expires in 24 hours. - * @param user The user for whom the recovery link is requested. - * @return The recovery link obtained from the server response. - * @throws IdpException If there is an issue with the identity or if the recovery link cannot be - * obtained. + * Sends a Kratos activation email to the specified user. */ @Throws(IdpException::class) - suspend fun getRecoveryLink(user: User): String = + suspend fun sendActivationEmail(user: User): String = withContext(Dispatchers.IO) { - val identityId = - user.identity - ?: throw IdpException( - "User ${user.login} could not be recovered on the IDP. No identity was set", - ) + val flowResponse = + httpClient.get { + url("$publicUrl/self-service/verification/api") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + }.body() - val response = + val flowId = flowResponse.id + + if (flowId == null) { + throw IdpException("Failed to initiate verification flow for ${user.email}") + } + + val activationResponse = httpClient.post { - url("$adminUrl/admin/recovery/link") + url("$publicUrl/self-service/verification?flow=$flowId") contentType(ContentType.Application.Json) accept(ContentType.Application.Json) - setBody(mapOf("expires_in" to "24h", "identity_id" to identityId)) + setBody(mapOf("email" to user.email, "method" to "code")) } - if (response.status.isSuccess()) { - response.body>()["recovery_link"]!!.also { - log.debug("Recovery link for user ${user.login} is $it") - } - } else { - throw IdpException("Couldn't get recovery link from server at $adminUrl") + if (!activationResponse.status.isSuccess()) { + throw IdpException("Failed to trigger verification email for ${user.email}") + } + + flowId.also { + log.debug("Activation email sent for user ${user.login} with flow ID $it") } } diff --git a/src/main/java/org/radarbase/management/service/UserService.kt b/src/main/java/org/radarbase/management/service/UserService.kt index c8b91831e..5f1736963 100644 --- a/src/main/java/org/radarbase/management/service/UserService.kt +++ b/src/main/java/org/radarbase/management/service/UserService.kt @@ -502,8 +502,8 @@ class UserService @Autowired constructor( } @Throws(IdpException::class) - suspend fun getRecoveryLink(user: User): String { - return identityService.getRecoveryLink(user) + suspend fun sendActivationEmail(user: User): String { + return identityService.sendActivationEmail(user) } companion object { diff --git a/src/main/java/org/radarbase/management/web/rest/UserResource.kt b/src/main/java/org/radarbase/management/web/rest/UserResource.kt index 455402df7..6b0a51a23 100644 --- a/src/main/java/org/radarbase/management/web/rest/UserResource.kt +++ b/src/main/java/org/radarbase/management/web/rest/UserResource.kt @@ -11,7 +11,6 @@ import org.radarbase.management.repository.filters.UserFilter import org.radarbase.management.security.Constants import org.radarbase.management.security.NotAuthorizedException import org.radarbase.management.service.AuthService -import org.radarbase.management.service.MailService import org.radarbase.management.service.ResourceUriService import org.radarbase.management.service.UserService import org.radarbase.management.service.dto.RoleDTO @@ -74,7 +73,6 @@ import java.util.* @RequestMapping("/api") class UserResource( @Autowired private val userRepository: UserRepository, - @Autowired private val mailService: MailService, @Autowired private val userService: UserService, @Autowired private val subjectRepository: SubjectRepository, @Autowired private val managementPortalProperties: ManagementPortalProperties, @@ -121,19 +119,7 @@ class UserResource( } else { val newUser: User; newUser = userService.createUser(managedUserVm) - - val recoveryLink = userService.getRecoveryLink(newUser) - - mailService.sendEmail( - newUser.email, - "Account Activation", - "Please click the link to activate your account:\n\n" + - "$recoveryLink \n\n" + - "Please activate your account before the link expires in 24 hours, and activate 2FA to enable" + - " access to the managementportal", - false, - false - ) + userService.sendActivationEmail(newUser) ResponseEntity.created(ResourceUriService.getUri(newUser)).headers( HeaderUtil.createAlert( From 60f0fa455df3087dedfad01a4c1533b1177c401a Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 10 Sep 2024 20:11:34 +0100 Subject: [PATCH 08/28] Add support for projects in Kratos identity --- .../java/org/radarbase/auth/kratos/KratosSessionDTO.kt | 9 +++++++++ src/main/docker/etc/config/kratos/kratos.yml | 2 +- .../org/radarbase/management/web/rest/KratosEndpoint.kt | 6 +++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index 17c998416..8aa60e54e 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -93,6 +93,15 @@ class KratosSessionDTO( class Traits ( val name: String? = null, val email: String? = null, + val projects: List? = null, + ) + + @Serializable + class Projects ( + val id: String? = null, + val name: String? = null, + val eligibility: Map? = null, + val consent: Map? = null, ) @Serializable diff --git a/src/main/docker/etc/config/kratos/kratos.yml b/src/main/docker/etc/config/kratos/kratos.yml index 36a824e98..b355cfbed 100644 --- a/src/main/docker/etc/config/kratos/kratos.yml +++ b/src/main/docker/etc/config/kratos/kratos.yml @@ -45,7 +45,7 @@ selfservice: enabled: true use: code after: - default_browser_return_url: http://localhost:3000/consent + default_browser_return_url: http://localhost:3000/study-consent logout: after: diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt index b7e39a858..bc4265f49 100644 --- a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -55,11 +55,11 @@ constructor( throw IllegalArgumentException("Cannot create non-subject users") val id = kratosIdentity.id ?: throw IllegalArgumentException("Identity ID is required") - val projectId = - webhookDTO.payload?.get("project_id") + val project = + kratosIdentity.traits.projects?.firstOrNull() ?: throw NotAuthorizedException("Cannot create subject without project") val projectDto = - projectService.findOneByName(projectId) + projectService.findOneByName(project.id!!) ?: throw NotFoundException( "Project not found: $projectId", EntityName.PROJECT, From 157b91576f01e2f27fab6e8a28fd98612086b8a3 Mon Sep 17 00:00:00 2001 From: Pauline Date: Wed, 11 Sep 2024 10:35:34 +0100 Subject: [PATCH 09/28] Fix getting project in webhook --- .../java/org/radarbase/management/web/rest/KratosEndpoint.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt index bc4265f49..3d976ba8f 100644 --- a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -56,12 +56,12 @@ constructor( val id = kratosIdentity.id ?: throw IllegalArgumentException("Identity ID is required") val project = - kratosIdentity.traits.projects?.firstOrNull() + kratosIdentity.traits!!.projects?.firstOrNull() ?: throw NotAuthorizedException("Cannot create subject without project") val projectDto = projectService.findOneByName(project.id!!) ?: throw NotFoundException( - "Project not found: $projectId", + "Project not found: ${project.id!!}", EntityName.PROJECT, "projectNotFound" ) From 6f1110adaea9b4b8bd202ca791b4da8700b06288 Mon Sep 17 00:00:00 2001 From: Pauline Date: Fri, 13 Sep 2024 14:28:47 +0100 Subject: [PATCH 10/28] Fix scopes --- .../java/org/radarbase/management/web/rest/LoginEndpoint.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt index a61b73b6d..d775afcf6 100644 --- a/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt @@ -48,7 +48,7 @@ constructor( "response_type=code&" + "state=${Instant.now()}&" + "audience=res_ManagementPortal&" + - "scope=offline&" + + "scope=SOURCEDATA.CREATE SOURCETYPE.UPDATE SOURCETYPE.DELETE AUTHORITY.UPDATE MEASUREMENT.DELETE PROJECT.READ AUDIT.CREATE USER.DELETE AUTHORITY.DELETE SUBJECT.DELETE MEASUREMENT.UPDATE SOURCEDATA.UPDATE SUBJECT.READ USER.UPDATE SOURCETYPE.CREATE AUTHORITY.READ USER.CREATE SOURCE.CREATE SOURCE.READ SUBJECT.CREATE ROLE.UPDATE ROLE.READ MEASUREMENT.READ PROJECT.UPDATE PROJECT.DELETE ROLE.DELETE SOURCE.DELETE SOURCETYPE.READ ROLE.CREATE SOURCEDATA.DELETE SUBJECT.UPDATE SOURCE.UPDATE PROJECT.CREATE AUDIT.READ MEASUREMENT.CREATE AUDIT.DELETE AUDIT.UPDATE AUTHORITY.CREATE USER.READ SOURCEDATA.READ&" + "redirect_uri=${managementPortalProperties.common.baseUrl}/api/redirect/login" } } From c4ff72f5fa9d9d07b5ba5e56c170fadd1160ef82 Mon Sep 17 00:00:00 2001 From: Pauline Date: Sat, 28 Sep 2024 20:15:33 +0100 Subject: [PATCH 11/28] Add specify project user id when creating subject --- .../java/org/radarbase/management/web/rest/KratosEndpoint.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt index 3d976ba8f..6b25b7ba2 100644 --- a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -58,6 +58,7 @@ constructor( val project = kratosIdentity.traits!!.projects?.firstOrNull() ?: throw NotAuthorizedException("Cannot create subject without project") + val projectUserId = project.userId ?: throw IllegalArgumentException("Project user ID is required") val projectDto = projectService.findOneByName(project.id!!) ?: throw NotFoundException( @@ -66,7 +67,7 @@ constructor( "projectNotFound" ) val subjectDto = - subjectService.createSubject(id, projectDto) + subjectService.createSubject(projectUserId, projectDto) ?: throw IllegalStateException("Failed to create subject for ID: $id") val user = userService.getUserWithAuthoritiesByLogin(subjectDto.login!!) From 30ac797ab27d737d90af849f8039c21e0345b40d Mon Sep 17 00:00:00 2001 From: Pauline Date: Sat, 28 Sep 2024 20:36:41 +0100 Subject: [PATCH 12/28] Update Project class to include user id --- .../main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index 8aa60e54e..c03751113 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -93,12 +93,13 @@ class KratosSessionDTO( class Traits ( val name: String? = null, val email: String? = null, - val projects: List? = null, + val projects: List? = null, ) @Serializable - class Projects ( + class Project ( val id: String? = null, + val userId: String? = null, val name: String? = null, val eligibility: Map? = null, val consent: Map? = null, From 580959cb48bac793d2bc5165e0d882ec8aa54376 Mon Sep 17 00:00:00 2001 From: Pauline Date: Sat, 28 Sep 2024 20:38:21 +0100 Subject: [PATCH 13/28] Update subject activation endpoint to use project user id --- .../org/radarbase/management/web/rest/KratosEndpoint.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt index 6b25b7ba2..666f64557 100644 --- a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -94,10 +94,15 @@ constructor( webhookDTO.cookies?.get("ory_kratos_session") ?: throw IllegalArgumentException("Session token is required") val kratosIdentity = sessionService.getSession(token).identity + val project = + kratosIdentity.traits!!.projects?.firstOrNull() + ?: throw NotAuthorizedException("Cannot create subject without project") + val projectUserId = project.userId ?: throw IllegalArgumentException("Project user ID is required") + if (!hasPermission(kratosIdentity, id)) { throw NotAuthorizedException("Not authorized to activate subject") } - subjectService.activateSubject(id) + subjectService.activateSubject(projectUserId) return ResponseEntity.ok() .headers(HeaderUtil.createEntityUpdateAlert(EntityName.SUBJECT, id)) .build() From fcfe2716a940a7ab415217a1eb26248f5c3c61c2 Mon Sep 17 00:00:00 2001 From: Pauline Date: Wed, 9 Oct 2024 01:19:00 +0800 Subject: [PATCH 14/28] Update Authservice and LoginEndpoint configs --- .../org/radarbase/management/service/AuthService.kt | 12 ++++++++---- .../radarbase/management/web/rest/LoginEndpoint.kt | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt index 6433af584..6fe1adddd 100644 --- a/src/main/java/org/radarbase/management/service/AuthService.kt +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -33,8 +33,8 @@ class AuthService( private val httpClient = HttpClient(CIO) { install(HttpTimeout) { - connectTimeoutMillis = Duration.ofSeconds(10).toMillis() - socketTimeoutMillis = Duration.ofSeconds(10).toMillis() + connectTimeoutMillis = Duration.ofSeconds(20).toMillis() + socketTimeoutMillis = Duration.ofSeconds(20).toMillis() requestTimeoutMillis = Duration.ofSeconds(300).toMillis() } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } @@ -104,8 +104,12 @@ class AuthService( suspend fun fetchAccessToken(code: String): String { val tokenUrl = "${managementPortalProperties.authServer.serverUrl}/oauth2/token" + val clientId = managementPortalProperties.frontend.clientId + val clientSecret = managementPortalProperties.frontend.clientSecret + val authHeader = "Basic " + Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray()) val response = httpClient.post(tokenUrl) { + headers { append(HttpHeaders.Authorization, authHeader) } contentType(ContentType.Application.FormUrlEncoded) accept(ContentType.Application.Json) setBody( @@ -114,11 +118,11 @@ class AuthService( append("code", code) append( "redirect_uri", - "${managementPortalProperties.common.baseUrl}/api/redirect/login" + "${managementPortalProperties.common.managementPortalBaseUrl}/api/redirect/login" ) append( "client_id", - managementPortalProperties.frontend.clientId + clientId ) } .formUrlEncode(), diff --git a/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt index d775afcf6..1e34d7b46 100644 --- a/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt @@ -30,7 +30,7 @@ constructor( } else { val accessToken = authService.fetchAccessToken(code) redirectView.url = - "${managementPortalProperties.common.baseUrl}/#/?access_token=$accessToken" + "${managementPortalProperties.common.managementPortalBaseUrl}/#/?access_token=$accessToken" } return redirectView } @@ -49,6 +49,6 @@ constructor( "state=${Instant.now()}&" + "audience=res_ManagementPortal&" + "scope=SOURCEDATA.CREATE SOURCETYPE.UPDATE SOURCETYPE.DELETE AUTHORITY.UPDATE MEASUREMENT.DELETE PROJECT.READ AUDIT.CREATE USER.DELETE AUTHORITY.DELETE SUBJECT.DELETE MEASUREMENT.UPDATE SOURCEDATA.UPDATE SUBJECT.READ USER.UPDATE SOURCETYPE.CREATE AUTHORITY.READ USER.CREATE SOURCE.CREATE SOURCE.READ SUBJECT.CREATE ROLE.UPDATE ROLE.READ MEASUREMENT.READ PROJECT.UPDATE PROJECT.DELETE ROLE.DELETE SOURCE.DELETE SOURCETYPE.READ ROLE.CREATE SOURCEDATA.DELETE SUBJECT.UPDATE SOURCE.UPDATE PROJECT.CREATE AUDIT.READ MEASUREMENT.CREATE AUDIT.DELETE AUDIT.UPDATE AUTHORITY.CREATE USER.READ SOURCEDATA.READ&" + - "redirect_uri=${managementPortalProperties.common.baseUrl}/api/redirect/login" + "redirect_uri=${managementPortalProperties.common.managementPortalBaseUrl}/api/redirect/login" } } From 968dae88474014f8997bef4bc39c2768c06ae026 Mon Sep 17 00:00:00 2001 From: Pauline Date: Mon, 25 Nov 2024 23:57:36 +0000 Subject: [PATCH 15/28] Refactor IdentityService to remove similar methods --- .../management/service/IdentityService.kt | 193 ++++++++---------- 1 file changed, 80 insertions(+), 113 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index 7eee8308d..3bb76918a 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -16,8 +16,9 @@ import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.exception.IdpException import org.radarbase.auth.kratos.KratosSessionDTO import org.radarbase.management.config.ManagementPortalProperties +import org.radarbase.management.domain.Role +import org.radarbase.management.domain.Subject import org.radarbase.management.domain.User -import org.radarbase.management.service.dto.UserDTO import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service @@ -59,53 +60,59 @@ class IdentityService } /** - * Convert a [User] to a [KratosSessionDTO.Identity] object. - * @param user The object to convert - * @return the newly created DTO object + * Builds metadata for a user based on roles, authorities, and sources. */ - @Throws(IdpException::class) - private fun createIdentity(user: User): KratosSessionDTO.Identity = + private fun buildMetadata( + roles: Set, + authorities: Set, + login: String, + sources: List = emptyList(), + ): KratosSessionDTO.Metadata = try { - KratosSessionDTO.Identity( - schema_id = "researcher", - traits = KratosSessionDTO.Traits(email = user.email), - metadata_public = - KratosSessionDTO.Metadata( - aud = emptyList(), - sources = emptyList(), - roles = - user.roles.mapNotNull { role -> - role.authority?.name?.let { auth -> - when (role.role?.scope) { - RoleAuthority.Scope.GLOBAL -> auth - RoleAuthority.Scope.ORGANIZATION -> - "${role.organization!!.name}:$auth" - RoleAuthority.Scope.PROJECT -> - "${role.project!!.projectName}:$auth" - null -> null - } - } - }, - authorities = user.authorities, - scope = - Permission.scopes().filter { scope -> - authService.mayBeGranted( - user.roles.mapNotNull { it.role }, - Permission.ofScope(scope), - ) - }, - mp_login = user.login, - ), + KratosSessionDTO.Metadata( + aud = emptyList(), + sources = sources, + roles = + roles.mapNotNull { role -> + role.authority?.name?.let { auth -> + when (role.role?.scope) { + RoleAuthority.Scope.GLOBAL -> auth + RoleAuthority.Scope.ORGANIZATION -> + "${role.organization!!.name}:$auth" + RoleAuthority.Scope.PROJECT -> + "${role.project!!.projectName}:$auth" + null -> null + } + } + }, + authorities = authorities, + scope = + Permission.scopes().filter { scope -> + authService.mayBeGranted( + roles.mapNotNull { it.role }, + Permission.ofScope(scope), + ) + }, + mp_login = login, ) } catch (e: Throwable) { - val message = "Could not convert user ${user.login} to identity" + val message = "Could not build metadata for user $login" log.error(message) throw IdpException(message, e) } - /** - * Save a [User] to the IDP as an identity. Returns the generated [KratosSessionDTO.Identity] - */ + private fun createIdentity(user: User): KratosSessionDTO.Identity = + KratosSessionDTO.Identity( + schema_id = "researcher", + traits = KratosSessionDTO.Traits(email = user.email), + metadata_public = + buildMetadata( + roles = user.roles, + authorities = user.authorities, + login = user.login!!, + ), + ) + @Throws(IdpException::class) suspend fun saveAsIdentity(user: User): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { @@ -127,19 +134,19 @@ class IdentityService } } - /** - * Update a [User] to the IDP as an identity. Returns the updated [KratosSessionDTO.Identity] - */ @Throws(IdpException::class) - suspend fun updateAssociatedIdentity(user: User): KratosSessionDTO.Identity = + suspend fun updateAssociatedIdentity( + user: User, + subject: Subject? = null, + ): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { val identityId = user.identity - ?: throw IdpException( - "User ${user.login} could not be updated on the IDP. No identity was set", - ) + ?: subject?.externalId ?: throw IdpException("User has no identity") - val identity = createIdentity(user) + val identity = getExistingIdentity(identityId) + val sources = subject?.sources?.map { it.sourceId.toString() } ?: emptyList() + identity.metadata_public = getIdentityMetadataWithRoles(user, sources) val response = httpClient.put { url("$adminUrl/admin/identities/$identityId") @@ -157,36 +164,39 @@ class IdentityService } } - /** - * Update [KratosSessionDTO.Identity] metadata with user roles. Returns the updated - * [KratosSessionDTO.Identity] - */ @Throws(IdpException::class) - suspend fun updateIdentityMetadataWithRoles( - identity: KratosSessionDTO.Identity, - user: UserDTO, - ): KratosSessionDTO.Identity = + suspend fun getExistingIdentity(identityId: String): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { - val updatedIdentity = identity.copy(metadata_public = getIdentityMetadata(user)) - val response = - httpClient.put { - url("$adminUrl/admin/identities/${updatedIdentity.id}") + httpClient.get { + url("$adminUrl/admin/identities/$identityId") contentType(ContentType.Application.Json) accept(ContentType.Application.Json) - setBody(updatedIdentity) } if (response.status.isSuccess()) { response.body().also { - log.debug("Updated identity for ${it.id}") + log.debug("Retrieved identity for ${it.id}") } } else { - throw IdpException("Couldn't update identity on server at $adminUrl") + throw IdpException("Couldn't retrieve identity from server at $adminUrl") } } - /** Delete a [User] from the IDP as an identity. */ + @Throws(IdpException::class) + suspend fun getIdentityMetadataWithRoles( + user: User, + sources: List, + ): KratosSessionDTO.Metadata = + withContext(Dispatchers.IO) { + buildMetadata( + roles = user.roles, + authorities = user.authorities, + login = user.login!!, + sources = sources, + ) + } + @Throws(IdpException::class) suspend fun deleteAssociatedIdentity(userIdentity: String?) = withContext(Dispatchers.IO) { @@ -210,59 +220,16 @@ class IdentityService } } - /** - * Convert a [UserDTO] to a [KratosSessionDTO.Metadata] object. - * @param user The object to convert - * @return the newly created DTO object - */ - @Throws(IdpException::class) - fun getIdentityMetadata(user: UserDTO): KratosSessionDTO.Metadata = - try { - KratosSessionDTO.Metadata( - aud = emptyList(), - sources = emptyList(), - roles = - user.roles.orEmpty().mapNotNull { role -> - role.authorityName?.let { auth -> - when { - role.projectName != null -> "${role.projectName}:$auth" - role.organizationName != null -> - "${role.organizationName}:$auth" - else -> auth - } - } - }, - authorities = user.authorities.orEmpty(), - scope = - Permission.scopes().filter { scope -> - authService.mayBeGranted( - user.roles?.mapNotNull { - RoleAuthority.valueOfAuthority(it.authorityName!!) - } - ?: emptyList(), - Permission.ofScope(scope), - ) - }, - mp_login = user.login, - ) - } catch (e: Throwable) { - val message = "Could not convert user ${user.login} to identity" - log.error(message) - throw IdpException(message, e) - } - - /** - * Sends a Kratos activation email to the specified user. - */ @Throws(IdpException::class) suspend fun sendActivationEmail(user: User): String = withContext(Dispatchers.IO) { val flowResponse = - httpClient.get { - url("$publicUrl/self-service/verification/api") - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - }.body() + httpClient + .get { + url("$publicUrl/self-service/verification/api") + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + }.body() val flowId = flowResponse.id From a18bc568b7c08f9f4cdf049626d93cf74f4339f8 Mon Sep 17 00:00:00 2001 From: Pauline Date: Mon, 25 Nov 2024 23:58:22 +0000 Subject: [PATCH 16/28] Add support for updating identity server with changes in Subject and User in MP --- .../management/service/SubjectService.kt | 30 +++++++++++++++---- .../management/service/UserService.kt | 15 ++++++++-- .../management/web/rest/KratosEndpoint.kt | 15 ++-------- .../management/web/rest/SubjectResource.kt | 6 ++-- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/SubjectService.kt b/src/main/java/org/radarbase/management/service/SubjectService.kt index 056e1d496..3b0b0d9e5 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.kt +++ b/src/main/java/org/radarbase/management/service/SubjectService.kt @@ -64,7 +64,9 @@ class SubjectService( @Autowired private val managementPortalProperties: ManagementPortalProperties, @Autowired private val passwordService: PasswordService, @Autowired private val authorityRepository: AuthorityRepository, - @Autowired private val authService: AuthService + @Autowired private val authService: AuthService, + @Autowired private val userService: UserService, + @Autowired private val identityService: IdentityService ) { /** @@ -74,7 +76,7 @@ class SubjectService( * @return the newly created subject */ @Transactional - fun createSubject(subjectDto: SubjectDTO, activated: Boolean? = true): SubjectDTO? { + suspend fun createSubject(subjectDto: SubjectDTO, activated: Boolean? = true): SubjectDTO? { val subject = subjectMapper.subjectDTOToSubject(subjectDto) ?: throw NullPointerException() // assign roles val user = subject.user @@ -107,14 +109,24 @@ class SubjectService( subject.enrollmentDate = ZonedDateTime.now() } sourceRepository.saveAll(subject.sources) - return subjectMapper.subjectToSubjectReducedProjectDTO(subjectRepository.save(subject)) + + val savedSubject = subjectRepository.save(subject) + val subjectDto = subjectMapper.subjectToSubjectReducedProjectDTO(savedSubject) + + // Update identity server identity with roles + userService.getUserWithAuthoritiesByLogin(login = subjectDto?.login!!)?.let { user -> + identityService.updateAssociatedIdentity(user, subject) + } + + return subjectDto } - fun createSubject(id: String, projectDto: ProjectDTO): SubjectDTO? { + suspend fun createSubject(id: String, projectDto: ProjectDTO, externalId: String): SubjectDTO? { return createSubject( SubjectDTO().apply { login = id project = projectDto + this.externalId = externalId }, activated = false ) @@ -165,7 +177,7 @@ class SubjectService( * @return the updated subject */ @Transactional - fun updateSubject(newSubjectDto: SubjectDTO): SubjectDTO? { + suspend fun updateSubject(newSubjectDto: SubjectDTO): SubjectDTO? { if (newSubjectDto.id == null) { return createSubject(newSubjectDto) } @@ -288,7 +300,7 @@ class SubjectService( * updates meta-data. */ @Transactional - fun assignOrUpdateSource( + suspend fun assignOrUpdateSource( subject: Subject, sourceType: SourceType, project: Project?, @@ -350,6 +362,12 @@ class SubjectService( ) } subjectRepository.save(subject) + + // Update identity server identity with roles + userService.getUserWithAuthoritiesByLogin(login = subject.user?.login!!)?.let { user -> + identityService.updateAssociatedIdentity(user, subject) + } + return sourceMapper.sourceToMinimalSourceDetailsDTO(assignedSource) } diff --git a/src/main/java/org/radarbase/management/service/UserService.kt b/src/main/java/org/radarbase/management/service/UserService.kt index 5f1736963..011e89a64 100644 --- a/src/main/java/org/radarbase/management/service/UserService.kt +++ b/src/main/java/org/radarbase/management/service/UserService.kt @@ -401,16 +401,27 @@ class UserService @Autowired constructor( } /** - * Get the user with the given login. + * Get the user dto with the given login. * @param login the login * @return an [Optional] which holds the user if one was found with the given login, * and is empty otherwise */ @Transactional(readOnly = true) - fun getUserWithAuthoritiesByLogin(login: String): UserDTO? { + fun getUserDtoWithAuthoritiesByLogin(login: String): UserDTO? { return userMapper.userToUserDTO(userRepository.findOneWithRolesByLogin(login)) } + /** + * Get the user with the given login. + * @param login the login + * @return an [Optional] which holds the user if one was found with the given login, + * and is empty otherwise + */ + @Transactional(readOnly = true) + fun getUserWithAuthoritiesByLogin(login: String): User? { + return userRepository.findOneWithRolesByLogin(login) + } + @Transactional(readOnly = true) /** * Get the current user. diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt index 666f64557..a76711c13 100644 --- a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -9,7 +9,6 @@ import org.radarbase.management.repository.SubjectRepository import org.radarbase.management.security.NotAuthorizedException import org.radarbase.management.service.* import org.radarbase.management.service.dto.KratosSubjectWebhookDTO -import org.radarbase.management.service.mapper.SubjectMapper import org.radarbase.management.web.rest.errors.EntityName import org.radarbase.management.web.rest.errors.NotFoundException import org.radarbase.management.web.rest.util.HeaderUtil @@ -26,10 +25,7 @@ constructor( @Autowired private val subjectService: SubjectService, @Autowired private val subjectRepository: SubjectRepository, @Autowired private val projectService: ProjectService, - @Autowired private val userService: UserService, - @Autowired private val identityService: IdentityService, @Autowired private val managementPortalProperties: ManagementPortalProperties, - @Autowired private val subjectMapper: SubjectMapper, ) { private var sessionService: SessionService = SessionService(managementPortalProperties.identityServer.publicUrl()) @@ -48,6 +44,7 @@ constructor( suspend fun createSubject( @RequestBody webhookDTO: KratosSubjectWebhookDTO, ): ResponseEntity { + logger.debug("REST request to create subject : $webhookDTO") val kratosIdentity = webhookDTO.identity ?: throw IllegalArgumentException("Identity is required") @@ -67,17 +64,9 @@ constructor( "projectNotFound" ) val subjectDto = - subjectService.createSubject(projectUserId, projectDto) + subjectService.createSubject(projectUserId, projectDto, id) ?: throw IllegalStateException("Failed to create subject for ID: $id") - val user = - userService.getUserWithAuthoritiesByLogin(subjectDto.login!!) - ?: throw NotFoundException( - "User not found with login: ${subjectDto.login}", - EntityName.USER, - "userNotFound" - ) - identityService.updateIdentityMetadataWithRoles(kratosIdentity, user) return ResponseEntity.created(ResourceUriService.getUri(subjectDto)) .headers(HeaderUtil.createEntityCreationAlert(EntityName.SUBJECT, id)) .build() diff --git a/src/main/java/org/radarbase/management/web/rest/SubjectResource.kt b/src/main/java/org/radarbase/management/web/rest/SubjectResource.kt index 3bba61551..51dcd16bd 100644 --- a/src/main/java/org/radarbase/management/web/rest/SubjectResource.kt +++ b/src/main/java/org/radarbase/management/web/rest/SubjectResource.kt @@ -84,7 +84,7 @@ class SubjectResource( @PostMapping("/subjects") @Timed @Throws(URISyntaxException::class, NotAuthorizedException::class) - fun createSubject(@RequestBody subjectDto: SubjectDTO): ResponseEntity { + suspend fun createSubject(@RequestBody subjectDto: SubjectDTO): ResponseEntity { log.debug("REST request to save Subject : {}", subjectDto) val projectName = getProjectName(subjectDto) authService.checkPermission(Permission.SUBJECT_CREATE, { e: EntityDetails -> e.project(projectName) }) @@ -135,7 +135,7 @@ class SubjectResource( @PutMapping("/subjects") @Timed @Throws(URISyntaxException::class, NotAuthorizedException::class) - fun updateSubject(@RequestBody subjectDto: SubjectDTO): ResponseEntity { + suspend fun updateSubject(@RequestBody subjectDto: SubjectDTO): ResponseEntity { log.debug("REST request to update Subject : {}", subjectDto) if (subjectDto.id == null) { return createSubject(subjectDto) @@ -385,7 +385,7 @@ class SubjectResource( ) @Timed @Throws(URISyntaxException::class, NotAuthorizedException::class) - fun assignSources( + suspend fun assignSources( @PathVariable login: String?, @RequestBody sourceDto: MinimalSourceDetailsDTO ): ResponseEntity { From ac468e418b3bc778f41ee5db939aee318df0c296 Mon Sep 17 00:00:00 2001 From: Pauline Date: Mon, 25 Nov 2024 23:58:43 +0000 Subject: [PATCH 17/28] Rename userService method --- .../java/org/radarbase/management/web/rest/UserResource.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/radarbase/management/web/rest/UserResource.kt b/src/main/java/org/radarbase/management/web/rest/UserResource.kt index 6b0a51a23..3c8eeca3b 100644 --- a/src/main/java/org/radarbase/management/web/rest/UserResource.kt +++ b/src/main/java/org/radarbase/management/web/rest/UserResource.kt @@ -214,7 +214,7 @@ class UserResource( log.debug("REST request to get User : {}", login) authService.checkPermission(Permission.USER_READ, { e: EntityDetails -> e.user(login) }) return ResponseUtil.wrapOrNotFound( - Optional.ofNullable(userService.getUserWithAuthoritiesByLogin(login)) + Optional.ofNullable(userService.getUserDtoWithAuthoritiesByLogin(login)) ) } @@ -251,7 +251,7 @@ class UserResource( log.debug("REST request to read User roles: {}", login) authService.checkPermission(Permission.ROLE_READ, { e: EntityDetails -> e.user(login) }) return ResponseUtil.wrapOrNotFound( - Optional.ofNullable(userService.getUserWithAuthoritiesByLogin(login).let { obj: UserDTO? -> obj?.roles }) + Optional.ofNullable(userService.getUserDtoWithAuthoritiesByLogin(login).let { obj: UserDTO? -> obj?.roles }) ) } From a20309884bb94cb6b3c743a426b53ddd3c500270 Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 26 Nov 2024 00:05:25 +0000 Subject: [PATCH 18/28] Save identity id when creating user --- src/main/java/org/radarbase/management/service/UserService.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/service/UserService.kt b/src/main/java/org/radarbase/management/service/UserService.kt index 011e89a64..2e65bd81a 100644 --- a/src/main/java/org/radarbase/management/service/UserService.kt +++ b/src/main/java/org/radarbase/management/service/UserService.kt @@ -170,7 +170,7 @@ class UserService @Autowired constructor( user.roles = getUserRoles(userDto.roles, mutableSetOf()) try { - identityService.saveAsIdentity(user) + user.identity = identityService.saveAsIdentity(user)?.id } catch (e: Throwable) { log.warn("could not save user ${user.login} as identity", e) @@ -383,6 +383,8 @@ class UserService @Autowired constructor( // there is no identity for this user, so we create it and save it to the IDP val id = identityService.saveAsIdentity(user) + // then save the identifier and update our database + user.identity = id?.id return userMapper.userToUserDTO(user) ?: throw Exception("Admin user could not be converted to DTO") From ca378a0acd4f4f8b1e230407e778ea9f58dff484 Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 26 Nov 2024 00:06:46 +0000 Subject: [PATCH 19/28] Format SubjectService --- .../management/service/SubjectService.kt | 409 +++++++++--------- 1 file changed, 214 insertions(+), 195 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/SubjectService.kt b/src/main/java/org/radarbase/management/service/SubjectService.kt index 3b0b0d9e5..26f03f467 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.kt +++ b/src/main/java/org/radarbase/management/service/SubjectService.kt @@ -1,13 +1,5 @@ package org.radarbase.management.service -import java.net.MalformedURLException -import java.net.URL -import java.time.ZonedDateTime -import java.util.* -import java.util.function.Consumer -import java.util.function.Function -import java.util.function.Predicate -import javax.annotation.Nonnull import org.hibernate.envers.query.AuditEntity import org.radarbase.auth.authorization.EntityDetails import org.radarbase.auth.authorization.Permission @@ -48,27 +40,34 @@ import org.springframework.data.domain.Page import org.springframework.data.history.Revision import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.net.MalformedURLException +import java.net.URL +import java.time.ZonedDateTime +import java.util.* +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.Predicate +import javax.annotation.Nonnull /** Created by nivethika on 26-5-17. */ @Service @Transactional class SubjectService( - @Autowired private val subjectMapper: SubjectMapper, - @Autowired private val projectMapper: ProjectMapper, - @Autowired private val subjectRepository: SubjectRepository, - @Autowired private val sourceRepository: SourceRepository, - @Autowired private val sourceMapper: SourceMapper, - @Autowired private val roleRepository: RoleRepository, - @Autowired private val groupRepository: GroupRepository, - @Autowired private val revisionService: RevisionService, - @Autowired private val managementPortalProperties: ManagementPortalProperties, - @Autowired private val passwordService: PasswordService, - @Autowired private val authorityRepository: AuthorityRepository, - @Autowired private val authService: AuthService, - @Autowired private val userService: UserService, - @Autowired private val identityService: IdentityService + @Autowired private val subjectMapper: SubjectMapper, + @Autowired private val projectMapper: ProjectMapper, + @Autowired private val subjectRepository: SubjectRepository, + @Autowired private val sourceRepository: SourceRepository, + @Autowired private val sourceMapper: SourceMapper, + @Autowired private val roleRepository: RoleRepository, + @Autowired private val groupRepository: GroupRepository, + @Autowired private val revisionService: RevisionService, + @Autowired private val managementPortalProperties: ManagementPortalProperties, + @Autowired private val passwordService: PasswordService, + @Autowired private val authorityRepository: AuthorityRepository, + @Autowired private val authService: AuthService, + @Autowired private val userService: UserService, + @Autowired private val identityService: IdentityService, ) { - /** * Create a new subject. * @@ -76,7 +75,10 @@ class SubjectService( * @return the newly created subject */ @Transactional - suspend fun createSubject(subjectDto: SubjectDTO, activated: Boolean? = true): SubjectDTO? { + suspend fun createSubject( + subjectDto: SubjectDTO, + activated: Boolean? = true, + ): SubjectDTO? { val subject = subjectMapper.subjectDTOToSubject(subjectDto) ?: throw NullPointerException() // assign roles val user = subject.user @@ -99,10 +101,10 @@ class SubjectService( // set if any devices are set as assigned if (subject.sources.isNotEmpty()) { subject.sources.forEach( - Consumer { s: Source -> - s.assigned = true - s.subject(subject) - } + Consumer { s: Source -> + s.assigned = true + s.subject(subject) + }, ) } if (subject.enrollmentDate == null) { @@ -121,31 +123,37 @@ class SubjectService( return subjectDto } - suspend fun createSubject(id: String, projectDto: ProjectDTO, externalId: String): SubjectDTO? { - return createSubject( - SubjectDTO().apply { - login = id - project = projectDto - this.externalId = externalId - }, - activated = false + suspend fun createSubject( + id: String, + projectDto: ProjectDTO, + externalId: String, + ): SubjectDTO? = + createSubject( + SubjectDTO().apply { + login = id + project = projectDto + this.externalId = externalId + }, + activated = false, ) - } - private fun getSubjectGroup(project: Project?, groupName: String?): Group? { - return if (project == null || groupName == null) { + private fun getSubjectGroup( + project: Project?, + groupName: String?, + ): Group? = + if (project == null || groupName == null) { null - } else - groupRepository.findByProjectIdAndName(project.id, groupName) - ?: throw BadRequestException( - "Group " + - groupName + - " does not exist in project " + - project.projectName, - EntityName.GROUP, - ErrorConstants.ERR_GROUP_NOT_FOUND - ) - } + } else { + groupRepository.findByProjectIdAndName(project.id, groupName) + ?: throw BadRequestException( + "Group " + + groupName + + " does not exist in project " + + project.projectName, + EntityName.GROUP, + ErrorConstants.ERR_GROUP_NOT_FOUND, + ) + } /** * Fetch Participant role of the project if available, otherwise create a new Role and assign. @@ -154,20 +162,25 @@ class SubjectService( * @return relevant Participant role * @throws java.util.NoSuchElementException if the authority name is not in the database */ - private fun getProjectParticipantRole(project: Project?, authority: RoleAuthority): Role { + private fun getProjectParticipantRole( + project: Project?, + authority: RoleAuthority, + ): Role { val ans: Role? = - roleRepository.findOneByProjectIdAndAuthorityName(project?.id, authority.authority) + roleRepository.findOneByProjectIdAndAuthorityName(project?.id, authority.authority) return if (ans == null) { val subjectRole = Role() val auth: Authority = - authorityRepository.findByAuthorityName(authority.authority) - ?: authorityRepository.save(Authority(authority)) + authorityRepository.findByAuthorityName(authority.authority) + ?: authorityRepository.save(Authority(authority)) subjectRole.authority = auth subjectRole.project = project roleRepository.save(subjectRole) subjectRole - } else ans + } else { + ans + } } /** @@ -187,7 +200,7 @@ class SubjectService( subjectMapper.safeUpdateSubjectFromDTO(newSubjectDto, subjectFromDb) sourcesToUpdate.addAll(subjectFromDb.sources) subjectFromDb.sources.forEach( - Consumer { s: Source -> s.subject(subjectFromDb).assigned = true } + Consumer { s: Source -> s.subject(subjectFromDb).assigned = true }, ) sourceRepository.saveAll(sourcesToUpdate) // update participant role @@ -195,7 +208,7 @@ class SubjectService( // Set group subjectFromDb.group = getSubjectGroup(subjectFromDb.activeProject, newSubjectDto.group) return subjectMapper.subjectToSubjectReducedProjectDTO( - subjectRepository.save(subjectFromDb) + subjectRepository.save(subjectFromDb), ) } @@ -205,37 +218,39 @@ class SubjectService( return subjectMapper.subjectToSubjectReducedProjectDTO(subjectRepository.save(subject)) } - private fun updateParticipantRoles(subject: Subject, subjectDto: SubjectDTO): MutableSet { + private fun updateParticipantRoles( + subject: Subject, + subjectDto: SubjectDTO, + ): MutableSet { if (subjectDto.project == null || subjectDto.project!!.projectName == null) { return subject.user!!.roles } val existingRoles = - subject.user!! - .roles - .map { - // make participant inactive in projects that do not match the new - // project - if (it.authority!!.name == RoleAuthority.PARTICIPANT.authority && - it.project!!.projectName != - subjectDto.project!!.projectName - ) { - return@map getProjectParticipantRole( - it.project, - RoleAuthority.INACTIVE_PARTICIPANT - ) - } else { - // do not modify other roles. - return@map it - } - } - .toMutableSet() + subject.user!! + .roles + .map { + // make participant inactive in projects that do not match the new + // project + if (it.authority!!.name == RoleAuthority.PARTICIPANT.authority && + it.project!!.projectName != + subjectDto.project!!.projectName + ) { + return@map getProjectParticipantRole( + it.project, + RoleAuthority.INACTIVE_PARTICIPANT, + ) + } else { + // do not modify other roles. + return@map it + } + }.toMutableSet() // Ensure that given project is present val newProjectRole = - getProjectParticipantRole( - projectMapper.projectDTOToProject(subjectDto.project), - RoleAuthority.PARTICIPANT - ) + getProjectParticipantRole( + projectMapper.projectDTOToProject(subjectDto.project), + RoleAuthority.PARTICIPANT, + ) existingRoles.add(newProjectRole) return existingRoles @@ -262,18 +277,17 @@ class SubjectService( return subjectMapper.subjectToSubjectReducedProjectDTO(subjectRepository.save(subject)) } - private fun ensureSubject(subjectDto: SubjectDTO): Subject { - return try { + private fun ensureSubject(subjectDto: SubjectDTO): Subject = + try { subjectDto.id?.let { subjectRepository.findById(it).get() } - ?: throw Exception("invalid subject ${subjectDto.login}: No ID") + ?: throw Exception("invalid subject ${subjectDto.login}: No ID") } catch (e: Throwable) { throw NotFoundException( - "Subject with ID " + subjectDto.id + " not found.", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND + "Subject with ID " + subjectDto.id + " not found.", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, ) } - } /** * Unassign all sources from a subject. This method saves the unassigned sources, but does NOT @@ -283,12 +297,12 @@ class SubjectService( */ private fun unassignAllSources(subject: Subject) { subject.sources.forEach( - Consumer { source: Source -> - source.assigned = false - source.subject = null - source.deleted = true - sourceRepository.save(source) - } + Consumer { source: Source -> + source.assigned = false + source.subject = null + source.deleted = true + sourceRepository.save(source) + }, ) subject.sources.clear() } @@ -301,10 +315,10 @@ class SubjectService( */ @Transactional suspend fun assignOrUpdateSource( - subject: Subject, - sourceType: SourceType, - project: Project?, - sourceRegistrationDto: MinimalSourceDetailsDTO + subject: Subject, + sourceType: SourceType, + project: Project?, + sourceRegistrationDto: MinimalSourceDetailsDTO, ): MinimalSourceDetailsDTO { val assignedSource: Source if (sourceRegistrationDto.sourceId != null) { @@ -312,17 +326,17 @@ class SubjectService( assignedSource = updateSourceAssignedSubject(subject, sourceRegistrationDto) } else if (sourceType.canRegisterDynamically!!) { val sources = - subjectRepository.findSubjectSourcesBySourceType( - subject.user!!.login, - sourceType.producer, - sourceType.model, - sourceType.catalogVersion - ) + subjectRepository.findSubjectSourcesBySourceType( + subject.user!!.login, + sourceType.producer, + sourceType.model, + sourceType.catalogVersion, + ) // create a source and register metadata // we allow only one source of a source-type per subject if (sources.isNullOrEmpty()) { var source = - Source(sourceType).project(project).sourceType(sourceType).subject(subject) + Source(sourceType).project(project).sourceType(sourceType).subject(subject) source.assigned = true source.attributes += sourceRegistrationDto.attributes // if source name is provided update source name @@ -333,11 +347,11 @@ class SubjectService( // make sure there is no source available on the same name. if (sourceRepository.findOneBySourceName(source.sourceName!!) != null) { throw ConflictException( - "SourceName already in use. Cannot create a " + - "source with existing source-name ", - EntityName.SUBJECT, - ErrorConstants.ERR_SOURCE_NAME_EXISTS, - Collections.singletonMap("source-name", source.sourceName) + "SourceName already in use. Cannot create a " + + "source with existing source-name ", + EntityName.SUBJECT, + ErrorConstants.ERR_SOURCE_NAME_EXISTS, + Collections.singletonMap("source-name", source.sourceName), ) } source = sourceRepository.save(source) @@ -345,20 +359,20 @@ class SubjectService( subject.sources.add(source) } else { throw ConflictException( - "A Source of SourceType with the specified producer, model and version" + - " was already registered for subject login", - EntityName.SUBJECT, - ErrorConstants.ERR_SOURCE_TYPE_EXISTS, - sourceTypeAttributes(sourceType, subject) + "A Source of SourceType with the specified producer, model and version" + + " was already registered for subject login", + EntityName.SUBJECT, + ErrorConstants.ERR_SOURCE_TYPE_EXISTS, + sourceTypeAttributes(sourceType, subject), ) } } else { // new source since sourceId == null, but canRegisterDynamically == false throw BadRequestException( - "The source type is not eligible for dynamic " + "registration", - EntityName.SOURCE_TYPE, - "error.InvalidDynamicSourceRegistration", - sourceTypeAttributes(sourceType, subject) + "The source type is not eligible for dynamic " + "registration", + EntityName.SOURCE_TYPE, + "error.InvalidDynamicSourceRegistration", + sourceTypeAttributes(sourceType, subject), ) } subjectRepository.save(subject) @@ -379,24 +393,24 @@ class SubjectService( * @return Updated [Source] instance. */ private fun updateSourceAssignedSubject( - subject: Subject, - sourceRegistrationDto: MinimalSourceDetailsDTO + subject: Subject, + sourceRegistrationDto: MinimalSourceDetailsDTO, ): Source { // for manually registered devices only add meta-data val source = - subjectRepository.findSubjectSourcesBySourceId( - subject.user?.login, - sourceRegistrationDto.sourceId - ) + subjectRepository.findSubjectSourcesBySourceId( + subject.user?.login, + sourceRegistrationDto.sourceId, + ) if (source == null) { val errorParams: MutableMap = HashMap() errorParams["sourceId"] = sourceRegistrationDto.sourceId.toString() errorParams["subject-login"] = subject.user?.login throw NotFoundException( - "No source with source-id to assigned to the subject with subject-login", - EntityName.SUBJECT, - ErrorConstants.ERR_SOURCE_NOT_FOUND, - errorParams + "No source with source-id to assigned to the subject with subject-login", + EntityName.SUBJECT, + ErrorConstants.ERR_SOURCE_NOT_FOUND, + errorParams, ) } @@ -416,10 +430,11 @@ class SubjectService( */ fun getSources(subject: Subject): List { val sources = subjectRepository.findSourcesBySubjectLogin(subject.user?.login) - if (sources.isEmpty()) - throw org.webjars.NotFoundException( - "Could not find sources for user ${subject.user}" - ) + if (sources.isEmpty()) { + throw org.webjars.NotFoundException( + "Could not find sources for user ${subject.user}", + ) + } return sourceMapper.sourcesToMinimalSourceDetailsDTOs(sources) } @@ -434,12 +449,12 @@ class SubjectService( subjectRepository.delete(subject) log.debug("Deleted Subject: {}", subject) } - ?: throw NotFoundException( - "subject not found for given login.", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) - ) + ?: throw NotFoundException( + "subject not found for given login.", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login), + ) } /** @@ -452,13 +467,14 @@ class SubjectService( val revisions = subject.id?.let { subjectRepository.findRevisions(it) } // collect distinct sources in a set val sources: List? = - revisions?.content - ?.flatMap { p: Revision -> p.entity.sources } - ?.distinctBy { obj: Source -> obj.sourceId } + revisions + ?.content + ?.flatMap { p: Revision -> p.entity.sources } + ?.distinctBy { obj: Source -> obj.sourceId } return sources - ?.map { p: Source -> sourceMapper.sourceToMinimalSourceDetailsDTO(p) } - ?.toList() + ?.map { p: Source -> sourceMapper.sourceToMinimalSourceDetailsDTO(p) } + ?.toList() } /** @@ -471,26 +487,29 @@ class SubjectService( * number */ @Throws(NotFoundException::class, NotAuthorizedException::class) - fun findRevision(login: String?, revision: Int?): SubjectDTO { + fun findRevision( + login: String?, + revision: Int?, + ): SubjectDTO { // first get latest known version of the subject, if it's deleted we can't load the entity // directly by e.g. findOneByLogin val latest = getLatestRevision(login) authService.checkPermission( - Permission.SUBJECT_READ, - { e: EntityDetails -> e.project(latest.project?.projectName).subject(latest.login) } + Permission.SUBJECT_READ, + { e: EntityDetails -> e.project(latest.project?.projectName).subject(latest.login) }, ) return revisionService.findRevision( - revision, - latest.id, - Subject::class.java, - subjectMapper::subjectToSubjectReducedProjectDTO + revision, + latest.id, + Subject::class.java, + subjectMapper::subjectToSubjectReducedProjectDTO, ) - ?: throw NotFoundException( - "subject not found for given login and revision.", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) - ) + ?: throw NotFoundException( + "subject not found for given login and revision.", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login), + ) } /** @@ -503,32 +522,32 @@ class SubjectService( @Throws(NotFoundException::class) fun getLatestRevision(login: String?): SubjectDTO { val user = - revisionService.getLatestRevisionForEntity( - User::class.java, - listOf(AuditEntity.property("login").eq(login)) - ) - .orElseThrow { - NotFoundException( - "Subject latest revision not found " + "for login", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) - ) - } as - UserDTO - return revisionService.getLatestRevisionForEntity( - Subject::class.java, - listOf(AuditEntity.property("user").eq(user)) - ) - .orElseThrow { + revisionService + .getLatestRevisionForEntity( + User::class.java, + listOf(AuditEntity.property("login").eq(login)), + ).orElseThrow { NotFoundException( - "Subject latest revision not found " + "for login", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND, - Collections.singletonMap("subjectLogin", login) + "Subject latest revision not found " + "for login", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login), ) } as - SubjectDTO + UserDTO + return revisionService + .getLatestRevisionForEntity( + Subject::class.java, + listOf(AuditEntity.property("user").eq(user)), + ).orElseThrow { + NotFoundException( + "Subject latest revision not found " + "for login", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + Collections.singletonMap("subjectLogin", login), + ) + } as + SubjectDTO } /** @@ -540,11 +559,11 @@ class SubjectService( fun findOneByLogin(login: String?): Subject { val subject = subjectRepository.findOneWithEagerBySubjectLogin(login) return subject - ?: throw NotFoundException( - "Subject not found with login", - EntityName.SUBJECT, - ErrorConstants.ERR_SUBJECT_NOT_FOUND - ) + ?: throw NotFoundException( + "Subject not found with login", + EntityName.SUBJECT, + ErrorConstants.ERR_SUBJECT_NOT_FOUND, + ) } /** @@ -570,11 +589,10 @@ class SubjectService( * @return URL of privacy policy for this token */ fun getPrivacyPolicyUrl(subject: Subject): URL { - // load default url from config val policyUrl: String = - subject.activeProject?.attributes?.get(ProjectDTO.PRIVACY_POLICY_URL) - ?: managementPortalProperties.common.privacyPolicyUrl + subject.activeProject?.attributes?.get(ProjectDTO.PRIVACY_POLICY_URL) + ?: managementPortalProperties.common.privacyPolicyUrl return try { URL(policyUrl) } catch (e: MalformedURLException) { @@ -582,20 +600,21 @@ class SubjectService( params["url"] = policyUrl params["message"] = e.message throw InvalidStateException( - "No valid privacy-policy Url configured. Please " + - "verify your project's privacy-policy url and/or general url config", - EntityName.OAUTH_CLIENT, - ErrorConstants.ERR_NO_VALID_PRIVACY_POLICY_URL_CONFIGURED, - params + "No valid privacy-policy Url configured. Please " + + "verify your project's privacy-policy url and/or general url config", + EntityName.OAUTH_CLIENT, + ErrorConstants.ERR_NO_VALID_PRIVACY_POLICY_URL_CONFIGURED, + params, ) } } companion object { private val log = LoggerFactory.getLogger(SubjectService::class.java) + private fun sourceTypeAttributes( - sourceType: SourceType, - subject: Subject + sourceType: SourceType, + subject: Subject, ): Map { val errorParams: MutableMap = HashMap() errorParams["producer"] = sourceType.producer From 5e52a1f66f012f280ac7b803f745aa639a6429b7 Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 26 Nov 2024 00:10:01 +0000 Subject: [PATCH 20/28] Fix tests --- ...capIntegrationWorkFlowOnServiceLevelTest.kt | 2 +- .../management/service/SubjectServiceTest.kt | 2 +- .../web/rest/GroupResourceIntTest.kt | 2 +- .../web/rest/SubjectResourceIntTest.kt | 18 +++++++++--------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.kt b/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.kt index e1ca52688..a568145ad 100644 --- a/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.kt +++ b/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.kt @@ -26,7 +26,7 @@ internal class RedcapIntegrationWorkFlowOnServiceLevelTest { @Autowired private val subjectService: SubjectService? = null @Test - fun testRedcapIntegrationWorkFlowOnServiceLevel() { + suspend fun testRedcapIntegrationWorkFlowOnServiceLevel() { val externalProjectUrl = "MyUrl" val externalProjectId = "MyId" val projectLocation = "London" diff --git a/src/test/java/org/radarbase/management/service/SubjectServiceTest.kt b/src/test/java/org/radarbase/management/service/SubjectServiceTest.kt index 0414c0ecc..96fb158ee 100644 --- a/src/test/java/org/radarbase/management/service/SubjectServiceTest.kt +++ b/src/test/java/org/radarbase/management/service/SubjectServiceTest.kt @@ -28,7 +28,7 @@ class SubjectServiceTest( @Test @Transactional - fun testGetPrivacyPolicyUrl() { + suspend fun testGetPrivacyPolicyUrl() { projectService.save(createEntityDTO().project!!) val c = createEntityDTO() val created = subjectService.createSubject(c) diff --git a/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt b/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt index 1c1a80d1a..56033c34a 100644 --- a/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt +++ b/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt @@ -293,7 +293,7 @@ internal class GroupResourceIntTest( @Test @Throws(Exception::class) - fun deleteGroupWithSubjects() { + suspend fun deleteGroupWithSubjects() { // Initialize the database groupRepository.saveAndFlush(group) val projectDto = projectMapper.projectToProjectDTO(project) diff --git a/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.kt b/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.kt index de0fc3a96..a438e7bc7 100644 --- a/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.kt +++ b/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.kt @@ -103,7 +103,7 @@ internal class SubjectResourceIntTest( @Test @Transactional @Throws(Exception::class) - fun createSubjectWithExistingId() { + suspend fun createSubjectWithExistingId() { // Create a Subject val subjectDto = subjectService.createSubject(SubjectServiceTest.createEntityDTO()) val databaseSizeBeforeCreate = subjectRepository.findAll().size @@ -122,7 +122,7 @@ internal class SubjectResourceIntTest( @Throws(Exception::class) @Transactional @Test - fun allSubjects() { + suspend fun allSubjects() { // Initialize the database val subjectDto = subjectService.createSubject(SubjectServiceTest.createEntityDTO()) @@ -150,7 +150,7 @@ internal class SubjectResourceIntTest( @Throws(Exception::class) @Transactional @Test - fun subject() { + suspend fun subject() { // Initialize the database val subjectDto = subjectService.createSubject(SubjectServiceTest.createEntityDTO()) @@ -179,7 +179,7 @@ internal class SubjectResourceIntTest( @Test @Transactional @Throws(Exception::class) - fun updateSubject() { + suspend fun updateSubject() { // Initialize the database var subjectDto = subjectService.createSubject(SubjectServiceTest.createEntityDTO()) val databaseSizeBeforeUpdate = subjectRepository.findAll().size @@ -206,7 +206,7 @@ internal class SubjectResourceIntTest( @Test @Transactional @Throws(Exception::class) - fun updateSubjectWithNewProject() { + suspend fun updateSubjectWithNewProject() { // Initialize the database var subjectDto = subjectService.createSubject(SubjectServiceTest.createEntityDTO()) val databaseSizeBeforeUpdate = subjectRepository.findAll().size @@ -259,7 +259,7 @@ internal class SubjectResourceIntTest( @Test @Transactional @Throws(Exception::class) - fun deleteSubject() { + suspend fun deleteSubject() { // Initialize the database val subjectDto = subjectService.createSubject(SubjectServiceTest.createEntityDTO()) val databaseSizeBeforeDelete = subjectRepository.findAll().size @@ -401,7 +401,7 @@ internal class SubjectResourceIntTest( @Throws(Exception::class) @Transactional @Test - fun subjectSources() { + suspend fun subjectSources() { // Initialize the database val subjectDtoToCreate: SubjectDTO = SubjectServiceTest.createEntityDTO() val createdSource = sourceService.save(createSource()) @@ -427,7 +427,7 @@ internal class SubjectResourceIntTest( @Throws(Exception::class) @Transactional @Test - fun subjectSourcesWithQueryParam() { + suspend fun subjectSourcesWithQueryParam() { // Initialize the database val subjectDtoToCreate: SubjectDTO = SubjectServiceTest.createEntityDTO() val createdSource = sourceService.save(createSource()) @@ -453,7 +453,7 @@ internal class SubjectResourceIntTest( @Throws(Exception::class) @Transactional @Test - fun inactiveSubjectSourcesWithQueryParam() { + suspend fun inactiveSubjectSourcesWithQueryParam() { // Initialize the database val subjectDtoToCreate: SubjectDTO = SubjectServiceTest.createEntityDTO() val createdSource = sourceService.save(createSource()) From f5f9b3359511386169ac09fe9ad221caed2341d0 Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 26 Nov 2024 00:16:09 +0000 Subject: [PATCH 21/28] Fix tests --- .../org/radarbase/management/web/rest/GroupResourceIntTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt b/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt index 56033c34a..ead5bcc7b 100644 --- a/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt +++ b/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.kt @@ -372,7 +372,7 @@ internal class GroupResourceIntTest( @Test @Throws(Exception::class) - fun addSubjectsToGroup() { + suspend fun addSubjectsToGroup() { // Initialize the database groupRepository.saveAndFlush(group) val projectDto = projectMapper.projectToProjectDTO(project) From 0ea522d50c6bd771c8206edbc1c45e2aa58334c3 Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 26 Nov 2024 00:59:16 +0000 Subject: [PATCH 22/28] Wrap updating of identity in try catch block in subject service --- .../management/service/SubjectService.kt | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/SubjectService.kt b/src/main/java/org/radarbase/management/service/SubjectService.kt index 26f03f467..ff65fe616 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.kt +++ b/src/main/java/org/radarbase/management/service/SubjectService.kt @@ -113,14 +113,15 @@ class SubjectService( sourceRepository.saveAll(subject.sources) val savedSubject = subjectRepository.save(subject) - val subjectDto = subjectMapper.subjectToSubjectReducedProjectDTO(savedSubject) - - // Update identity server identity with roles - userService.getUserWithAuthoritiesByLogin(login = subjectDto?.login!!)?.let { user -> - identityService.updateAssociatedIdentity(user, subject) + return subjectMapper.subjectToSubjectReducedProjectDTO(savedSubject).also { + userService.getUserWithAuthoritiesByLogin(login = subjectDto.login!!)?.let { user -> + try { + identityService.updateAssociatedIdentity(user, savedSubject) + } catch (ex: Exception) { + log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) + } + } } - - return subjectDto } suspend fun createSubject( @@ -377,12 +378,15 @@ class SubjectService( } subjectRepository.save(subject) - // Update identity server identity with roles - userService.getUserWithAuthoritiesByLogin(login = subject.user?.login!!)?.let { user -> - identityService.updateAssociatedIdentity(user, subject) + return sourceMapper.sourceToMinimalSourceDetailsDTO(assignedSource).also { + userService.getUserWithAuthoritiesByLogin(login = subject.user?.login!!)?.let { user -> + try { + identityService.updateAssociatedIdentity(user, subject) + } catch (ex: Exception) { + log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) + } + } } - - return sourceMapper.sourceToMinimalSourceDetailsDTO(assignedSource) } /** From 1c27e75db2401d70d251938ba20103a25ff87535 Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 26 Nov 2024 01:19:12 +0000 Subject: [PATCH 23/28] Check IdentityService is enabled before calling methods --- .../management/service/IdentityService.kt | 1 + .../management/service/SubjectService.kt | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index 3bb76918a..cc524e659 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -53,6 +53,7 @@ class IdentityService private val adminUrl = managementPortalProperties.identityServer.adminUrl() private val publicUrl = managementPortalProperties.identityServer.publicUrl() + public val enabled = !adminUrl.isNullOrEmpty() && !publicUrl.isNullOrEmpty() init { log.debug("Kratos serverUrl set to $publicUrl") diff --git a/src/main/java/org/radarbase/management/service/SubjectService.kt b/src/main/java/org/radarbase/management/service/SubjectService.kt index ff65fe616..23731a51c 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.kt +++ b/src/main/java/org/radarbase/management/service/SubjectService.kt @@ -114,11 +114,13 @@ class SubjectService( val savedSubject = subjectRepository.save(subject) return subjectMapper.subjectToSubjectReducedProjectDTO(savedSubject).also { - userService.getUserWithAuthoritiesByLogin(login = subjectDto.login!!)?.let { user -> - try { - identityService.updateAssociatedIdentity(user, savedSubject) - } catch (ex: Exception) { - log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) + if (identityService.enabled) { + userService.getUserWithAuthoritiesByLogin(login = subjectDto.login!!)?.let { user -> + try { + identityService.updateAssociatedIdentity(user, savedSubject) + } catch (ex: Exception) { + log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) + } } } } @@ -380,10 +382,12 @@ class SubjectService( return sourceMapper.sourceToMinimalSourceDetailsDTO(assignedSource).also { userService.getUserWithAuthoritiesByLogin(login = subject.user?.login!!)?.let { user -> - try { - identityService.updateAssociatedIdentity(user, subject) - } catch (ex: Exception) { - log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) + if (identityService.enabled) { + try { + identityService.updateAssociatedIdentity(user, subject) + } catch (ex: Exception) { + log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) + } } } } From 3e83eb47beca1dd53df40e07063c88f269e4d3bd Mon Sep 17 00:00:00 2001 From: Pauline Date: Tue, 3 Dec 2024 18:14:45 +0000 Subject: [PATCH 24/28] Fix scopes in login endpoint --- .../java/org/radarbase/management/web/rest/LoginEndpoint.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt index 1e34d7b46..06aebd72a 100644 --- a/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/LoginEndpoint.kt @@ -48,7 +48,7 @@ constructor( "response_type=code&" + "state=${Instant.now()}&" + "audience=res_ManagementPortal&" + - "scope=SOURCEDATA.CREATE SOURCETYPE.UPDATE SOURCETYPE.DELETE AUTHORITY.UPDATE MEASUREMENT.DELETE PROJECT.READ AUDIT.CREATE USER.DELETE AUTHORITY.DELETE SUBJECT.DELETE MEASUREMENT.UPDATE SOURCEDATA.UPDATE SUBJECT.READ USER.UPDATE SOURCETYPE.CREATE AUTHORITY.READ USER.CREATE SOURCE.CREATE SOURCE.READ SUBJECT.CREATE ROLE.UPDATE ROLE.READ MEASUREMENT.READ PROJECT.UPDATE PROJECT.DELETE ROLE.DELETE SOURCE.DELETE SOURCETYPE.READ ROLE.CREATE SOURCEDATA.DELETE SUBJECT.UPDATE SOURCE.UPDATE PROJECT.CREATE AUDIT.READ MEASUREMENT.CREATE AUDIT.DELETE AUDIT.UPDATE AUTHORITY.CREATE USER.READ SOURCEDATA.READ&" + + "scope=SOURCEDATA.CREATE SOURCETYPE.UPDATE SOURCETYPE.DELETE AUTHORITY.UPDATE MEASUREMENT.DELETE PROJECT.READ AUDIT.CREATE USER.DELETE AUTHORITY.DELETE SUBJECT.DELETE MEASUREMENT.UPDATE SOURCEDATA.UPDATE SUBJECT.READ USER.UPDATE SOURCETYPE.CREATE AUTHORITY.READ USER.CREATE SOURCE.CREATE SOURCE.READ SUBJECT.CREATE ROLE.UPDATE ROLE.READ MEASUREMENT.READ PROJECT.UPDATE PROJECT.DELETE ROLE.DELETE SOURCE.DELETE SOURCETYPE.READ ROLE.CREATE SOURCEDATA.DELETE SUBJECT.UPDATE SOURCE.UPDATE PROJECT.CREATE AUDIT.READ MEASUREMENT.CREATE AUDIT.DELETE AUDIT.UPDATE AUTHORITY.CREATE USER.READ ORGANIZATION.READ ORGANIZATION.CREATE ORGANIZATION.UPDATE SOURCEDATA.READ&" + "redirect_uri=${managementPortalProperties.common.managementPortalBaseUrl}/api/redirect/login" } } From d6239391f878e7fbe9ac5ceb20c27a0775c0b79b Mon Sep 17 00:00:00 2001 From: Pauline Date: Thu, 12 Dec 2024 17:08:04 +0000 Subject: [PATCH 25/28] Update IdentityService to patch existing identity instead of replacing --- .../radarbase/auth/kratos/KratosSessionDTO.kt | 13 +++++++++--- .../management/service/IdentityService.kt | 20 +++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index c03751113..d11a1855d 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder import org.radarbase.auth.authorization.AuthorityReference +import kotlinx.serialization.json.JsonElement import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.token.DataRadarToken import org.radarbase.auth.token.RadarToken @@ -76,7 +77,7 @@ class KratosSessionDTO( add(AuthorityReference(authority)) } } - } + } metadata_public?.roles?.takeIf { it.isNotEmpty() }?.let { roles -> for (roleValue in roles) { val role = RoleAuthority.valueOfAuthorityOrNull(roleValue) @@ -101,8 +102,6 @@ class KratosSessionDTO( val id: String? = null, val userId: String? = null, val name: String? = null, - val eligibility: Map? = null, - val consent: Map? = null, ) @Serializable @@ -115,6 +114,14 @@ class KratosSessionDTO( val mp_login: String? = null, ) + @Serializable + data class JsonMetadataPatchOperation( + val op: String, + val path: String, + val value: Metadata + ) + + fun toDataRadarToken() : DataRadarToken { return DataRadarToken( roles = this.identity.parseRoles(), diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index cc524e659..073e61fe9 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -10,11 +10,13 @@ import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json +import kotlinx.serialization.* +import kotlinx.serialization.json.* import org.radarbase.auth.authorization.Permission import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.exception.IdpException import org.radarbase.auth.kratos.KratosSessionDTO +import org.radarbase.auth.kratos.KratosSessionDTO.JsonMetadataPatchOperation import org.radarbase.management.config.ManagementPortalProperties import org.radarbase.management.domain.Role import org.radarbase.management.domain.Subject @@ -24,6 +26,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Duration +import kotlinx.serialization.Serializable /** Service class for managing identities. */ @Service @@ -141,19 +144,24 @@ class IdentityService subject: Subject? = null, ): KratosSessionDTO.Identity = withContext(Dispatchers.IO) { + val json = Json { ignoreUnknownKeys = true } val identityId = user.identity ?: subject?.externalId ?: throw IdpException("User has no identity") - - val identity = getExistingIdentity(identityId) val sources = subject?.sources?.map { it.sourceId.toString() } ?: emptyList() - identity.metadata_public = getIdentityMetadataWithRoles(user, sources) + val jsonPatchPayload = listOf( + JsonMetadataPatchOperation( + op = "replace", + path = "/metadata_public", + value = getIdentityMetadataWithRoles(user, sources) + ) + ) val response = - httpClient.put { + httpClient.patch { url("$adminUrl/admin/identities/$identityId") contentType(ContentType.Application.Json) accept(ContentType.Application.Json) - setBody(identity) + setBody(json.encodeToString(jsonPatchPayload)) } if (response.status.isSuccess()) { From a80f6624075a4358d04022a7f887f017db594f6e Mon Sep 17 00:00:00 2001 From: Pauline Date: Sun, 12 Jan 2025 22:24:29 +0000 Subject: [PATCH 26/28] Remove unnecessary check if IdentityService is enabled --- .../java/org/radarbase/management/service/IdentityService.kt | 1 - .../java/org/radarbase/management/service/SubjectService.kt | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/IdentityService.kt b/src/main/java/org/radarbase/management/service/IdentityService.kt index 073e61fe9..ca16bd96b 100644 --- a/src/main/java/org/radarbase/management/service/IdentityService.kt +++ b/src/main/java/org/radarbase/management/service/IdentityService.kt @@ -56,7 +56,6 @@ class IdentityService private val adminUrl = managementPortalProperties.identityServer.adminUrl() private val publicUrl = managementPortalProperties.identityServer.publicUrl() - public val enabled = !adminUrl.isNullOrEmpty() && !publicUrl.isNullOrEmpty() init { log.debug("Kratos serverUrl set to $publicUrl") diff --git a/src/main/java/org/radarbase/management/service/SubjectService.kt b/src/main/java/org/radarbase/management/service/SubjectService.kt index 23731a51c..1f8119764 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.kt +++ b/src/main/java/org/radarbase/management/service/SubjectService.kt @@ -114,7 +114,6 @@ class SubjectService( val savedSubject = subjectRepository.save(subject) return subjectMapper.subjectToSubjectReducedProjectDTO(savedSubject).also { - if (identityService.enabled) { userService.getUserWithAuthoritiesByLogin(login = subjectDto.login!!)?.let { user -> try { identityService.updateAssociatedIdentity(user, savedSubject) @@ -122,7 +121,6 @@ class SubjectService( log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) } } - } } } @@ -382,13 +380,11 @@ class SubjectService( return sourceMapper.sourceToMinimalSourceDetailsDTO(assignedSource).also { userService.getUserWithAuthoritiesByLogin(login = subject.user?.login!!)?.let { user -> - if (identityService.enabled) { try { identityService.updateAssociatedIdentity(user, subject) } catch (ex: Exception) { log.error("Failed to update associated identity for user {}: {}", user.login, ex.message) } - } } } } From c0b5c013337d4be34ec377807ba9a66a3593ff70 Mon Sep 17 00:00:00 2001 From: Pauline Date: Sun, 12 Jan 2025 22:25:28 +0000 Subject: [PATCH 27/28] Add check for kratos identity before creating subject through webhook --- .../radarbase/management/service/dto/UserDTO.kt | 4 ++++ .../management/web/rest/KratosEndpoint.kt | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/dto/UserDTO.kt b/src/main/java/org/radarbase/management/service/dto/UserDTO.kt index a69e0c533..6ce15db93 100644 --- a/src/main/java/org/radarbase/management/service/dto/UserDTO.kt +++ b/src/main/java/org/radarbase/management/service/dto/UserDTO.kt @@ -24,6 +24,10 @@ open class UserDTO { var authorities: Set? = null var accessToken: String? = null + /** Identifier for association with the identity service provider. + * Null if not linked to an external identity. */ + var identity: String? = null + override fun toString(): String { return ("UserDTO{" + "login='" + login + '\'' diff --git a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt index a76711c13..08ee2521e 100644 --- a/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt +++ b/src/main/java/org/radarbase/management/web/rest/KratosEndpoint.kt @@ -25,6 +25,7 @@ constructor( @Autowired private val subjectService: SubjectService, @Autowired private val subjectRepository: SubjectRepository, @Autowired private val projectService: ProjectService, + @Autowired private val identityService: IdentityService, @Autowired private val managementPortalProperties: ManagementPortalProperties, ) { private var sessionService: SessionService = @@ -47,13 +48,18 @@ constructor( logger.debug("REST request to create subject : $webhookDTO") val kratosIdentity = webhookDTO.identity ?: throw IllegalArgumentException("Identity is required") + val id = kratosIdentity.id ?: throw IllegalArgumentException("Identity ID is required") + + // Verify kratos identity exists + val existingIdentity = identityService.getExistingIdentity(id) + if (!existingIdentity.equals(kratosIdentity)) + throw IllegalArgumentException("Kratos identity does not match") if (!kratosIdentity.schema_id.equals(KRATOS_SUBJECT_SCHEMA)) throw IllegalArgumentException("Cannot create non-subject users") - val id = kratosIdentity.id ?: throw IllegalArgumentException("Identity ID is required") val project = - kratosIdentity.traits!!.projects?.firstOrNull() + kratosIdentity.traits?.projects?.firstOrNull() ?: throw NotAuthorizedException("Cannot create subject without project") val projectUserId = project.userId ?: throw IllegalArgumentException("Project user ID is required") val projectDto = @@ -84,8 +90,8 @@ constructor( ?: throw IllegalArgumentException("Session token is required") val kratosIdentity = sessionService.getSession(token).identity val project = - kratosIdentity.traits!!.projects?.firstOrNull() - ?: throw NotAuthorizedException("Cannot create subject without project") + kratosIdentity.traits?.projects?.firstOrNull() + ?: throw NotAuthorizedException("Cannot create subject without project") val projectUserId = project.userId ?: throw IllegalArgumentException("Project user ID is required") if (!hasPermission(kratosIdentity, id)) { @@ -106,4 +112,4 @@ constructor( private val logger = LoggerFactory.getLogger(KratosEndpoint::class.java) private val KRATOS_SUBJECT_SCHEMA = "subject" } -} \ No newline at end of file +} From baa9297673c7edb4fdae4882dd624ed797feb35b Mon Sep 17 00:00:00 2001 From: Pauline Date: Sun, 12 Jan 2025 22:35:10 +0000 Subject: [PATCH 28/28] Revert unnecessary changes --- .../org/radarbase/auth/kratos/KratosSessionDTO.kt | 2 -- .../radarbase/management/service/AuthService.kt | 4 ++-- .../user-management-dialog.component.html | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt index d11a1855d..f81f711e9 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/kratos/KratosSessionDTO.kt @@ -6,7 +6,6 @@ import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder import org.radarbase.auth.authorization.AuthorityReference -import kotlinx.serialization.json.JsonElement import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.token.DataRadarToken import org.radarbase.auth.token.RadarToken @@ -121,7 +120,6 @@ class KratosSessionDTO( val value: Metadata ) - fun toDataRadarToken() : DataRadarToken { return DataRadarToken( roles = this.identity.parseRoles(), diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt index 6fe1adddd..b44f9343c 100644 --- a/src/main/java/org/radarbase/management/service/AuthService.kt +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -33,8 +33,8 @@ class AuthService( private val httpClient = HttpClient(CIO) { install(HttpTimeout) { - connectTimeoutMillis = Duration.ofSeconds(20).toMillis() - socketTimeoutMillis = Duration.ofSeconds(20).toMillis() + connectTimeoutMillis = Duration.ofSeconds(10).toMillis() + socketTimeoutMillis = Duration.ofSeconds(10).toMillis() requestTimeoutMillis = Duration.ofSeconds(300).toMillis() } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } diff --git a/src/main/webapp/app/admin/user-management/user-management-dialog.component.html b/src/main/webapp/app/admin/user-management/user-management-dialog.component.html index 6cc941d9e..2f280b0d0 100644 --- a/src/main/webapp/app/admin/user-management/user-management-dialog.component.html +++ b/src/main/webapp/app/admin/user-management/user-management-dialog.component.html @@ -34,6 +34,20 @@
+
+ + + +
+ + +
+
+