From b710b4d2ddcfcfaa687072dc63df5861db1cf51d Mon Sep 17 00:00:00 2001 From: Vladislav Frolov <50615459+Cheshiriks@users.noreply.github.com> Date: Mon, 26 Feb 2024 14:43:24 +0300 Subject: [PATCH] Fixed organization view in cosv (#2926) --- .../LnkUserOrganizationController.kt | 318 ++++++++++++++++++ .../backend/controllers/ProjectController.kt | 75 +++++ .../com/saveourtool/save/entities/Git.kt | 2 + .../com/saveourtool/save/entities/Project.kt | 3 + .../LnkUserOrganizationRepository.kt | 8 + .../service/LnkUserOrganizationService.kt | 21 ++ 6 files changed, 427 insertions(+) create mode 100644 cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/LnkUserOrganizationController.kt create mode 100644 cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/ProjectController.kt diff --git a/cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/LnkUserOrganizationController.kt b/cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/LnkUserOrganizationController.kt new file mode 100644 index 0000000000..b1be478fce --- /dev/null +++ b/cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/LnkUserOrganizationController.kt @@ -0,0 +1,318 @@ +/** + * Controller for processing links between users and their roles in organizations: + * 1) to put new roles of users + * 2) to get users and their roles by organization + * 3) to remove users from organizations + */ + +package com.saveourtool.cosv.backend.controllers + +import com.saveourtool.save.configs.ApiSwaggerSupport +import com.saveourtool.save.configs.RequiresAuthorizationSourceHeader +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.Organization +import com.saveourtool.save.entities.OrganizationWithUsers +import com.saveourtool.save.filters.OrganizationFilter +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.permission.Permission +import com.saveourtool.save.permission.SetRoleRequest +import com.saveourtool.save.security.OrganizationPermissionEvaluator +import com.saveourtool.save.service.LnkUserOrganizationService +import com.saveourtool.save.service.OrganizationService +import com.saveourtool.save.utils.StringResponse +import com.saveourtool.save.utils.switchIfEmptyToNotFound +import com.saveourtool.save.utils.switchIfEmptyToResponseException +import com.saveourtool.save.utils.username +import com.saveourtool.save.v1 + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import io.swagger.v3.oas.annotations.tags.Tags +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.kotlin.core.publisher.toMono +import reactor.kotlin.core.util.function.component1 +import reactor.kotlin.core.util.function.component2 + +/** + * Controller for processing links between users and their roles in organizations + */ +@ApiSwaggerSupport +@Tags( + Tag(name = "roles"), + Tag(name = "organizations"), +) +@RestController +@RequestMapping("/api/$v1/organizations") +class LnkUserOrganizationController( + private val lnkUserOrganizationService: LnkUserOrganizationService, + private val organizationService: OrganizationService, + private val organizationPermissionEvaluator: OrganizationPermissionEvaluator, +) { + @GetMapping("/{organizationName}/users") + @Operation( + method = "GET", + summary = "Get list of users that are connected with given organization.", + description = "Get list of users that are connected with given organization.", + ) + @Parameters( + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched contest by it's name.") + @ApiResponse(responseCode = "404", description = "Contest with such name was not found.") + fun getAllUsersByOrganizationName( + @PathVariable organizationName: String, + ): Mono> = organizationService.findByNameAndCreatedStatus(organizationName) + .toMono() + .switchIfEmptyToNotFound { + ORGANIZATION_NOT_FOUND_ERROR_MESSAGE + } + .map { + lnkUserOrganizationService.getAllUsersAndRolesByOrganization(it) + } + .map { mapOfPermissions -> + mapOfPermissions.map { (user, role) -> + user.toUserInfo(organizations = mapOf(organizationName to role)) + } + } + + @GetMapping("/{organizationName}/users/roles") + @Operation( + method = "GET", + summary = "Get user's role in organization with given name.", + description = "If userName is not present, then will return the role of current user in given organization, " + + "otherwise will return role of user with name userName in organization with name organizationName.", + ) + @Parameters( + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "userName", `in` = ParameterIn.QUERY, description = "name of a user", required = false), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched user's role.") + @ApiResponse(responseCode = "403", description = "You are not allowed to see requested user's role.") + @ApiResponse(responseCode = "404", description = "Requested user or organization doesn't exist.") + @Suppress("TOO_MANY_LINES_IN_LAMBDA", "UnsafeCallOnNullableType") + fun getRole( + @PathVariable organizationName: String, + authentication: Authentication?, + ): Mono = authentication?.let { + getUserAndOrganizationWithPermissions( + authentication.username(), + organizationName, + Permission.READ, + authentication, + ) + .map { (user, organization) -> + lnkUserOrganizationService.getRole(user, organization) + } + } ?: Role.NONE.toMono() + + @PostMapping("/{organizationName}/users/roles") + @RequiresAuthorizationSourceHeader + @PreAuthorize("permitAll()") + @Operation( + method = "POST", + summary = "Set user's role in organization with given name.", + description = "Set user's role in organization with given name.", + ) + @Parameters( + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "setRoleRequest", `in` = ParameterIn.DEFAULT, description = "pair of userName and role that is requested to be set", required = true), + ) + @ApiResponse(responseCode = "200", description = "Permission added") + @ApiResponse(responseCode = "403", description = "User doesn't have permissions to manage this members") + @ApiResponse(responseCode = "404", description = "Requested user or organization doesn't exist") + fun setRole( + @PathVariable organizationName: String, + @RequestBody setRoleRequest: SetRoleRequest, + authentication: Authentication, + ): Mono = getUserAndOrganizationWithPermissions(setRoleRequest.userName, organizationName, Permission.WRITE, authentication) + .filter { (user, organization) -> + organizationPermissionEvaluator.canChangeRoles(organization, authentication, user, setRoleRequest.role) + } + .switchIfEmptyToResponseException(HttpStatus.FORBIDDEN) { + FORBIDDEN_ERROR_MESSAGE + } + .map { (user, organization) -> + lnkUserOrganizationService.setRole(user, organization, setRoleRequest.role) + ResponseEntity.ok( + "Successfully set role ${setRoleRequest.role} to user ${user.name} in organization ${organization.name}" + ) + } + + @DeleteMapping("/{organizationName}/users/roles/{userName}") + @RequiresAuthorizationSourceHeader + @PreAuthorize("permitAll()") + @Operation( + method = "DELETE", + summary = "Remove user's role in organization with given name.", + description = "Remove user's role in organization with given name.", + ) + @Parameters( + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "userName", `in` = ParameterIn.PATH, description = "name of user whose role is requested to be removed", required = true), + ) + @ApiResponse(responseCode = "200", description = "Role was successfully removed") + @ApiResponse(responseCode = "403", description = "User doesn't have permissions to manage this members") + @ApiResponse(responseCode = "404", description = "Requested user or organization doesn't exist") + fun removeRole( + @PathVariable organizationName: String, + @PathVariable userName: String, + authentication: Authentication, + ): Mono = getUserAndOrganizationWithPermissions(userName, organizationName, Permission.WRITE, authentication) + .filter { (user, organization) -> + organizationPermissionEvaluator.canChangeRoles(organization, authentication, user) + } + .switchIfEmptyToResponseException(HttpStatus.FORBIDDEN) { + FORBIDDEN_ERROR_MESSAGE + } + .map { (user, organization) -> + lnkUserOrganizationService.removeRole(user, organization) + ResponseEntity.ok("Successfully removed role of user ${user.name} in organization ${organization.name}") + } + + @GetMapping("/{organizationName}/users/not-from") + @RequiresAuthorizationSourceHeader + @PreAuthorize("permitAll()") + @Operation( + method = "GET", + summary = "Get all users not from organization with names starting with a given prefix.", + description = "Get all users not connected with organization with name organizationName whose names start with the same prefix.", + ) + @Parameters( + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "prefix", `in` = ParameterIn.QUERY, description = "prefix of username", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched list of users") + @ApiResponse(responseCode = "404", description = "Requested organization doesn't exist") + @Suppress("TOO_LONG_FUNCTION") + fun getAllUsersNotFromOrganizationWithNamesStartingWith( + @PathVariable organizationName: String, + @RequestParam prefix: String, + ): Mono> = Mono.just(organizationName) + .filter { + prefix.isNotEmpty() + } + .flatMap { + organizationService.findByNameAndCreatedStatus(it).toMono() + } + .switchIfEmptyToNotFound { + "No organization with name $organizationName was found." + } + .map { organization -> + lnkUserOrganizationService.getAllUsersAndRolesByOrganization(organization) + } + .map { users -> + users.map { (user, _) -> user.requiredId() }.toSet() + } + .map { organizationUserIds -> + organizationUserIds to lnkUserOrganizationService.getNonOrganizationUsersByName(prefix, organizationUserIds) + } + .map { (organizationUserIds, exactMatchUsers) -> + exactMatchUsers to + lnkUserOrganizationService.getNonOrganizationUsersByNamePrefix( + prefix, + organizationUserIds + exactMatchUsers.map { it.requiredId() }, + PAGE_SIZE - exactMatchUsers.size, + ) + } + .map { (exactMatchUsers, prefixUsers) -> + (exactMatchUsers + prefixUsers).map { it.toUserInfo() } + } + .defaultIfEmpty(emptyList()) + + @GetMapping("/can-create-contests") + @RequiresAuthorizationSourceHeader + @PreAuthorize("permitAll()") + @Operation( + method = "GET", + summary = "Get all user's organizations that can create contests.", + description = "Get all organizations that can create contests where user is a member.", + ) + @ApiResponse(responseCode = "200", description = "Role removed") + @ApiResponse(responseCode = "403", description = "User doesn't have permissions to manage this members") + @ApiResponse(responseCode = "404", description = "Requested user or organization doesn't exist") + fun getAllUsersOrganizationsThatCanCreateContests( + authentication: Authentication, + ): Flux = Flux.fromIterable( + lnkUserOrganizationService.getSuperOrganizationsWithRole(authentication.username()) + ) + + @PostMapping("/by-filters") + @RequiresAuthorizationSourceHeader + @PreAuthorize("permitAll()") + @Operation( + method = "POST", + summary = "Get the list of organizations available to the current user and matching the filters, if any", + description = "Get organizations by filters available for the current user.", + ) + @Parameters( + Parameter(name = "filters", `in` = ParameterIn.DEFAULT, description = "organization filters", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched organization infos.") + @ApiResponse(responseCode = "404", description = "Could not find user with this id.") + @Suppress("UnsafeCallOnNullableType") + fun getOrganizationWithRolesAndFilters( + @RequestBody organizationFilter: OrganizationFilter, + authentication: Authentication, + ): Flux = Mono.justOrEmpty( + lnkUserOrganizationService.findUserByName(authentication.username()) + ) + .switchIfEmptyToNotFound() + .flatMapIterable { + lnkUserOrganizationService.getOrganizationsAndRolesByUserAndFilters(it, organizationFilter) + } + .map { + OrganizationWithUsers( + organization = it.organization.toDto(), + userRoles = mapOf(it.user.name to it.role), + ) + } + + private fun getUserAndOrganizationWithPermissions( + userName: String, + organizationName: String, + permission: Permission, + authentication: Authentication?, + ) = Mono.just(userName) + .filter { + it.isNotBlank() + } + .switchIfEmptyToNotFound { + USER_NOT_FOUND_ERROR_MESSAGE + } + .zipWith( + organizationService.findByNameAndCreatedStatus(organizationName).toMono() + ) + .switchIfEmptyToNotFound { + ORGANIZATION_NOT_FOUND_ERROR_MESSAGE + } + .filter { (_, organization) -> + organizationPermissionEvaluator.hasPermission(authentication, organization, permission) + } + .switchIfEmptyToResponseException(HttpStatus.FORBIDDEN) + .flatMap { (userName, organization) -> + Mono.zip( + lnkUserOrganizationService.getUserByName(userName).toMono(), + organization.toMono() + ) + } + .switchIfEmptyToNotFound { + USER_NOT_FOUND_ERROR_MESSAGE + } + + companion object { + private const val FORBIDDEN_ERROR_MESSAGE = "Not enough permission." + private const val ORGANIZATION_NOT_FOUND_ERROR_MESSAGE = "Organization with such name does not exist." + const val PAGE_SIZE = 5 + private const val USER_NOT_FOUND_ERROR_MESSAGE = "User with such name does not exist." + } +} diff --git a/cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/ProjectController.kt b/cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/ProjectController.kt new file mode 100644 index 0000000000..0dcc11a9a2 --- /dev/null +++ b/cosv-backend/src/main/kotlin/com/saveourtool/cosv/backend/controllers/ProjectController.kt @@ -0,0 +1,75 @@ +package com.saveourtool.cosv.backend.controllers + +import com.saveourtool.save.configs.ApiSwaggerSupport +import com.saveourtool.save.configs.RequiresAuthorizationSourceHeader +import com.saveourtool.save.entities.* +import com.saveourtool.save.filters.ProjectFilter +import com.saveourtool.save.permission.Permission +import com.saveourtool.save.security.ProjectPermissionEvaluator +import com.saveourtool.save.service.ProjectService +import com.saveourtool.save.utils.* +import com.saveourtool.save.v1 + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import io.swagger.v3.oas.annotations.tags.Tags +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Flux +import java.util.* + +/** + * Controller for working with projects. + */ +@ApiSwaggerSupport +@Tags( + Tag(name = "projects"), +) +@RestController +@RequestMapping(path = ["/api/$v1/projects"]) +class ProjectController( + private val projectService: ProjectService, + private val projectPermissionEvaluator: ProjectPermissionEvaluator, +) { + @GetMapping("/") + @RequiresAuthorizationSourceHeader + @PreAuthorize("permitAll()") + @Operation( + method = "GET", + summary = "Get all available projects.", + description = "Get all projects, available for current user.", + ) + @ApiResponse(responseCode = "200", description = "Projects successfully fetched.") + fun getProjects( + authentication: Authentication, + ): Flux = projectService.getProjects() + .filter { + projectPermissionEvaluator.hasPermission(authentication, it, Permission.READ) + } + + @PostMapping("/by-filters") + @PreAuthorize("permitAll()") + @Operation( + method = "POST", + summary = "Get projects matching filters", + description = "Get filtered projects available for the current user.", + ) + @Parameters( + Parameter(name = "projectFilter", `in` = ParameterIn.DEFAULT, description = "project filters", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched projects.") + fun getFilteredProjects( + @RequestBody projectFilter: ProjectFilter, + authentication: Authentication?, + ): Flux = + blockingToFlux { projectService.getFiltered(projectFilter) } + .filter { + projectPermissionEvaluator.hasPermission(authentication, it, Permission.READ) + } + .map { it.toDto() } +} diff --git a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Git.kt b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Git.kt index c44849b9e2..b27cff715e 100644 --- a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Git.kt +++ b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Git.kt @@ -4,6 +4,7 @@ import com.saveourtool.save.spring.entity.BaseEntityWithDto import javax.persistence.Entity import javax.persistence.JoinColumn import javax.persistence.ManyToOne +import javax.persistence.Table /** * Data class with repository information @@ -15,6 +16,7 @@ import javax.persistence.ManyToOne * @property organization */ @Entity +@Table(schema = "save_cloud", name = "git") class Git( var url: String, var username: String? = null, diff --git a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Project.kt b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Project.kt index 1abdac4d28..8430465b6a 100644 --- a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Project.kt +++ b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/Project.kt @@ -20,6 +20,7 @@ import kotlinx.serialization.Serializable */ @Entity @Serializable +@Table(schema = "save_cloud", name = "project") data class Project( var name: String, var url: String?, @@ -28,11 +29,13 @@ data class Project( var status: ProjectStatus, var public: Boolean = true, var email: String? = null, + @Column(name = "number_of_containers") var numberOfContainers: Int = 3, @ManyToOne @JoinColumn(name = "organization_id") var organization: Organization, + @Column(name = "contest_rating") var contestRating: Double = 0.0, ) : BaseEntityWithDto() { /** diff --git a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/repository/LnkUserOrganizationRepository.kt b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/repository/LnkUserOrganizationRepository.kt index d9126b6a6a..68b02121a4 100644 --- a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/repository/LnkUserOrganizationRepository.kt +++ b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/repository/LnkUserOrganizationRepository.kt @@ -58,6 +58,14 @@ interface LnkUserOrganizationRepository : BaseEntityRepository): List + /** + * @param userName + * @param canCreateContests flag that indicates if organization can create contests + * @param roles list of roles that are required for user + * @return list of [LnkUserOrganization] where user has role from [roles] and [Organization] can create contests + */ + fun findByUserNameAndOrganizationCanCreateContestsAndRoleIn(userName: String, canCreateContests: Boolean, roles: List): List + /** * @param userId * @param organizationId diff --git a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/service/LnkUserOrganizationService.kt b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/service/LnkUserOrganizationService.kt index daf3b67fdc..1bcee22c46 100644 --- a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/service/LnkUserOrganizationService.kt +++ b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/service/LnkUserOrganizationService.kt @@ -7,6 +7,7 @@ import com.saveourtool.save.repository.LnkUserOrganizationRepository import com.saveourtool.save.repository.UserRepository import com.saveourtool.save.utils.blockingToFlux import com.saveourtool.save.utils.getHighestRole +import com.saveourtool.save.utils.orNotFound import com.saveourtool.save.utils.username import org.springframework.data.domain.PageRequest @@ -32,6 +33,12 @@ class LnkUserOrganizationService( lnkUserOrganizationRepository.findByOrganization(organization) .associate { it.user to it.role } + /** + * @param userName + * @return user with [userName] + */ + fun findUserByName(userName: String): User = userRepository.findByName(userName).orNotFound { "Not found user by name $userName" } + /** * @param userName * @param organization @@ -235,6 +242,20 @@ class LnkUserOrganizationService( } .map { it.organization } + /** + * @param userName + * @param requestedRole role that user with name [userName] should have in organization + * @return list of organizations that can create contests + */ + fun getSuperOrganizationsWithRole(userName: String, requestedRole: Role = Role.OWNER): List = Role.values() + .filter { + it.isHigherOrEqualThan(requestedRole) + } + .let { + lnkUserOrganizationRepository.findByUserNameAndOrganizationCanCreateContestsAndRoleIn(userName, true, it) + } + .map { it.organization } + /** * @param user * @param filters