diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt index 4be354d67d..a295de3e35 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.isAdmin import io.tolgee.dtos.request.apiKey.CreateApiKeyDto import io.tolgee.dtos.request.apiKey.RegenerateApiKeyDto import io.tolgee.dtos.request.apiKey.V2EditApiKeyDto @@ -21,7 +22,6 @@ import io.tolgee.hateoas.apiKey.RevealedApiKeyModel import io.tolgee.hateoas.apiKey.RevealedApiKeyModelAssembler import io.tolgee.hateoas.project.SimpleProjectModelAssembler import io.tolgee.model.ApiKey -import io.tolgee.model.UserAccount import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope import io.tolgee.openApiDocs.OpenApiOrderExtension @@ -77,7 +77,7 @@ class ApiKeyController( dto: CreateApiKeyDto, ): RevealedApiKeyModel { val project = projectService.get(dto.projectId) - if (authenticationFacade.authenticatedUser.role != UserAccount.Role.ADMIN) { + if (!authenticationFacade.authenticatedUser.isAdmin()) { securityService.checkApiKeyScopes(dto.scopes, project) } return apiKeyService.create( @@ -312,7 +312,7 @@ class ApiKeyController( ) @Deprecated(message = "Don't use this endpoint, it's useless.") val scopes: Map> by lazy { - ProjectPermissionType.values() + ProjectPermissionType.entries .associate { it -> it.name to it.availableScopes.map { it.value }.toList() } } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt index fe64027797..07bcdebb45 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/InitialDataController.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.api.EeSubscriptionProvider import io.tolgee.component.PreferredOrganizationFacade +import io.tolgee.hateoas.auth.AuthInfoModelAssembler import io.tolgee.hateoas.initialData.InitialDataEeSubscriptionModel import io.tolgee.hateoas.initialData.InitialDataModel import io.tolgee.hateoas.sso.PublicSsoTenantModelAssembler @@ -33,6 +34,7 @@ class InitialDataController( private val preferredOrganizationFacade: PreferredOrganizationFacade, private val announcementController: AnnouncementController, private val tenantService: TenantService, + private val authInfoModelAssembler: AuthInfoModelAssembler, private val privateUserAccountModelAssembler: PrivateUserAccountModelAssembler, private val publicSsoTenantModelAssembler: PublicSsoTenantModelAssembler, private val eeSubscriptionProvider: EeSubscriptionProvider?, @@ -49,6 +51,7 @@ class InitialDataController( if (userAccount != null) { val userAccountView = authenticationFacade.authenticatedUserView val tenant = tenantService.getEnabledConfigByDomainOrNull(userAccount.domain) + data.authInfo = authInfoModelAssembler.toModel(authenticationFacade.authentication) data.userInfo = privateUserAccountModelAssembler.toModel(userAccountView) data.ssoInfo = tenant?.let { publicSsoTenantModelAssembler.toModel(it) } data.preferredOrganization = preferredOrganizationFacade.getPreferred() diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/SlugController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/SlugController.kt index 750550127f..46efc6e9e1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/SlugController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/SlugController.kt @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.dtos.request.GenerateSlugDto import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.service.organization.OrganizationService import io.tolgee.service.project.ProjectService import jakarta.validation.Valid @@ -46,6 +47,7 @@ class SlugController( } @PostMapping("/generate-organization", produces = [MediaType.APPLICATION_JSON_VALUE]) + @ReadOnlyOperation @Operation(summary = "Generate organization slug") fun generateOrganizationSlug( @RequestBody @Valid @@ -55,6 +57,7 @@ class SlugController( } @PostMapping("/generate-project", produces = [MediaType.APPLICATION_JSON_VALUE]) + @ReadOnlyOperation @Operation(summary = "Generate project slug") @OpenApiHideFromPublicDocs fun generateProjectSlug( diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserMfaController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserMfaController.kt index 164d606612..c4f55af817 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserMfaController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserMfaController.kt @@ -37,7 +37,7 @@ class UserMfaController( ): JwtAuthenticationResponse { mfaService.enableTotpFor(authenticationFacade.authenticatedUserEntity, dto) return JwtAuthenticationResponse( - jwtService.emitToken(authenticationFacade.authenticatedUser.id, true), + jwtService.emitTokenRefreshForCurrentUser(isSuper = true), ) } @@ -53,7 +53,7 @@ class UserMfaController( ): JwtAuthenticationResponse { mfaService.disableTotpFor(authenticationFacade.authenticatedUserEntity, dto) return JwtAuthenticationResponse( - jwtService.emitToken(authenticationFacade.authenticatedUser.id, true), + jwtService.emitTokenRefreshForCurrentUser(isSuper = true), ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2InvitationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2InvitationController.kt index e39b3caf30..87d579cc2f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2InvitationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2InvitationController.kt @@ -17,6 +17,7 @@ import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.RequiresSuperAuthentication +import io.tolgee.security.authentication.WriteOperation import io.tolgee.security.authorization.RequiresOrganizationRole import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.service.TranslationAgencyService @@ -53,7 +54,12 @@ class V2InvitationController( private val publicInvitationModelAssembler: PublicInvitationModelAssembler, ) { @GetMapping("/v2/invitations/{code}/accept") - @Operation(summary = "Accepts invitation to project or organization") + @WriteOperation + @Operation( + summary = "Accepts invitation to project or organization " + + "(deprecated: use PUT method instead)", + deprecated = true + ) fun acceptInvitation( @PathVariable("code") code: String?, ): ResponseEntity { @@ -61,6 +67,15 @@ class V2InvitationController( return ResponseEntity(HttpStatus.OK) } + @PutMapping("/v2/invitations/{code}/accept") + @Operation(summary = "Accepts invitation to project or organization") + fun acceptInvitationPut( + @PathVariable("code") code: String?, + ): ResponseEntity { + invitationService.accept(code) + return ResponseEntity(HttpStatus.OK) + } + @DeleteMapping("/v2/invitations/{invitationId}") @Operation(summary = "Deletes invitation by ID") fun deleteInvitation( @@ -78,7 +93,9 @@ class V2InvitationController( } invitation.organizationRole?.let { - organizationRoleService.checkUserIsOwner(invitation.organizationRole!!.organization!!.id) + organizationRoleService.checkUserCanDeleteInvitation( + invitation.organizationRole!!.organization!!.id, + ) } invitationService.delete(invitation) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt index 1c59bf8954..1116ac2756 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt @@ -117,7 +117,7 @@ class V2UserController( ): JwtAuthenticationResponse { userAccountService.updatePassword(authenticationFacade.authenticatedUserEntity, dto!!) return JwtAuthenticationResponse( - jwtService.emitToken(authenticationFacade.authenticatedUser.id, true), + jwtService.emitTokenRefreshForCurrentUser(), ) } @@ -282,7 +282,7 @@ class V2UserController( if (!matches) throw AuthenticationException(Message.WRONG_CURRENT_PASSWORD) } - val jwt = jwtService.emitToken(entity.id, true) + val jwt = jwtService.emitTokenRefreshForCurrentUser(isSuper = true) return ResponseEntity.ok(JwtAuthenticationResponse(jwt)) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationController.kt index 76562a3dda..1f53557c59 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationController.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.api.v2.controllers.IController import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.isAdmin import io.tolgee.dtos.queryResults.organization.OrganizationView import io.tolgee.exceptions.BadRequestException import io.tolgee.hateoas.organization.OrganizationModel @@ -148,7 +149,18 @@ class AdministrationController( fun generateUserToken( @PathVariable userId: Long, ): String { - val user = userAccountService.get(userId) - return jwtService.emitToken(user.id, true) + val isAlreadyImpersonating = authenticationFacade.actingUser != null + if (isAlreadyImpersonating) { + // We don't want to recreate the Inception movie here + throw BadRequestException(Message.ALREADY_IMPERSONATING_USER) + } + + val actingUser = authenticationFacade.authenticatedUser + val user = userAccountService.getDto(userId) + if (user.isAdmin() && !actingUser.isAdmin()) { + // We don't allow impersonation of admin by supporters + throw BadRequestException(Message.IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED) + } + return jwtService.emitImpersonationToken(user.id) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt index d739f49054..f835b99d41 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt @@ -10,6 +10,7 @@ import io.tolgee.openApiDocs.OpenApiOrderExtension import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.service.export.ExportService import io.tolgee.service.language.LanguageService @@ -70,6 +71,7 @@ class V2ExportController( summary = "Export data (post)", description = """Exports data (post). Useful when exceeding allowed URL size.""", ) + @ReadOnlyOperation @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess @ExportApiResponse diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt index 04e357a5c0..044d437106 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt @@ -32,6 +32,7 @@ import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs import io.tolgee.openApiDocs.OpenApiOrderExtension import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.key.KeySearchResultView @@ -293,6 +294,7 @@ class KeyController( "Returns information about keys. (KeyData, Screenshots, Translation in specified language)" + "If key is not found, it's not included in the response.", ) + @ReadOnlyOperation @RequiresProjectPermissions([Scope.KEYS_VIEW, Scope.SCREENSHOTS_VIEW, Scope.TRANSLATIONS_VIEW]) @AllowApiAccess fun getInfo( diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt index d56dbaa023..c6b595f9b1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt @@ -10,6 +10,7 @@ import io.tolgee.component.mtBucketSizeProvider.PayAsYouGoCreditsProvider import io.tolgee.component.translationsLimitProvider.LimitsProvider import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.isAdmin import io.tolgee.dtos.queryResults.organization.OrganizationView import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.dtos.request.organization.OrganizationRequestParamsDto @@ -24,7 +25,6 @@ import io.tolgee.hateoas.organization.PublicUsageModel import io.tolgee.hateoas.organization.UserAccountWithOrganizationRoleModel import io.tolgee.hateoas.organization.UserAccountWithOrganizationRoleModelAssembler import io.tolgee.model.Project -import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.ThirdPartyAuthType @@ -106,12 +106,12 @@ class OrganizationController( dto: OrganizationDto, ): ResponseEntity { if (!this.tolgeeProperties.authentication.userCanCreateOrganizations && - authenticationFacade.authenticatedUser.role != UserAccount.Role.ADMIN + !authenticationFacade.authenticatedUser.isAdmin() ) { throw PermissionException() } if (authenticationFacade.authenticatedUserEntity.thirdPartyAuthType === ThirdPartyAuthType.SSO && - authenticationFacade.authenticatedUser.role != UserAccount.Role.ADMIN + !authenticationFacade.authenticatedUser.isAdmin() ) { throw PermissionException(Message.SSO_USER_CANNOT_CREATE_ORGANIZATION) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt index d557a3ca69..cb6b8133d4 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt @@ -85,7 +85,7 @@ class ProjectsController( @RequestBody @Valid dto: CreateProjectRequest, ): ProjectModel { - organizationRoleService.checkUserIsOwnerOrMaintainer(dto.organizationId) + organizationRoleService.checkUserCanCreateProject(dto.organizationId) val project = projectCreationService.createProject(dto) if (organizationRoleService.getType(dto.organizationId) == OrganizationRoleType.MAINTAINER) { // Maintainers get full access to projects they create @@ -241,11 +241,11 @@ class ProjectsController( ) @RequiresProjectPermissions([ Scope.MEMBERS_EDIT ]) @RequiresSuperAuthentication - fun setOrganizationBase( + fun removeDirectProjectPermissions( @PathVariable("userId") userId: Long, ) { projectPermissionFacade.checkNotCurrentUser(userId) - permissionService.setOrganizationBasePermissions( + permissionService.removeDirectProjectPermissions( projectId = projectHolder.project.id, userId = userId, ) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsTransferringController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsTransferringController.kt index 46272423f3..4d275e82a6 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsTransferringController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsTransferringController.kt @@ -72,7 +72,7 @@ class ProjectsTransferringController( @PathVariable projectId: Long, @PathVariable organizationId: Long, ) { - organizationRoleService.checkUserIsOwner(organizationId) + organizationRoleService.checkUserCanTransferProjectToOrganization(organizationId) projectService.transferToOrganization(projectId, organizationId) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt index d18b4bd71d..dfebdb10dc 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt @@ -14,6 +14,7 @@ import io.tolgee.model.key.Key import io.tolgee.model.views.TranslationMemoryItemView import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.key.KeyService @@ -96,6 +97,7 @@ class TranslationSuggestionController( "Suggests machine translations from translation memory. " + "The result is always sorted by similarity, so sorting is not supported.", ) + @ReadOnlyOperation @UseDefaultPermissions @AllowApiAccess fun suggestTranslationMemory( diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt index 733878e338..9034317699 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt @@ -79,9 +79,7 @@ class AuthProviderChangeController( val user = authenticationFacade.authenticatedUserEntity authProviderChangeService.accept(user, request.id) userAccountService.invalidateTokens(user) - return JwtAuthenticationResponse( - jwtService.emitToken(authenticationFacade.authenticatedUser.id, true), - ) + return JwtAuthenticationResponse(jwtService.emitTokenRefreshForCurrentUser(isSuper = true)) } @DeleteMapping("/change") diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt index 65501785ac..911596e2cb 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -12,6 +12,7 @@ import io.tolgee.exceptions.DisabledFunctionalityException import io.tolgee.hateoas.invitation.PublicInvitationModel import io.tolgee.hateoas.invitation.PublicInvitationModelAssembler import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs +import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.security.ratelimit.RateLimited @@ -51,6 +52,7 @@ class PublicController( private val thirdPartyAuthenticationService: ThirdPartyAuthenticationService, private val publicInvitationModelAssembler: PublicInvitationModelAssembler, private val invitationService: InvitationService, + private val authenticationFacade: AuthenticationFacade, ) { @Operation(summary = "Generate JWT token") @PostMapping("/generatetoken") @@ -67,7 +69,7 @@ class PublicController( mfaService.checkMfa(userAccount, loginRequest.otp) // two factor passed, so we can generate super token - val jwt = jwtService.emitToken(userAccount.id, true) + val jwt = jwtService.emitToken(userAccount.id, isSuper = true) return JwtAuthenticationResponse(jwt) } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModel.kt new file mode 100644 index 0000000000..5187478813 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModel.kt @@ -0,0 +1,7 @@ +package io.tolgee.hateoas.auth + +import org.springframework.hateoas.RepresentationModel + +class AuthInfoModel( + val isReadOnly: Boolean +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModelAssembler.kt new file mode 100644 index 0000000000..832f15d162 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModelAssembler.kt @@ -0,0 +1,18 @@ +package io.tolgee.hateoas.auth + +import io.tolgee.api.v2.controllers.InitialDataController +import io.tolgee.security.authentication.TolgeeAuthentication +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class AuthInfoModelAssembler : RepresentationModelAssemblerSupport( + InitialDataController::class.java, + AuthInfoModel::class.java, +) { + override fun toModel(entity: TolgeeAuthentication): AuthInfoModel { + return AuthInfoModel( + isReadOnly = entity.isReadOnly, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.kt index 59cb54f4f1..f2c2c63ae1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/initialData/InitialDataModel.kt @@ -2,12 +2,14 @@ package io.tolgee.hateoas.initialData import io.tolgee.api.publicConfiguration.PublicConfigurationDTO import io.tolgee.dtos.response.AnnouncementDto +import io.tolgee.hateoas.auth.AuthInfoModel import io.tolgee.hateoas.organization.PrivateOrganizationModel import io.tolgee.hateoas.sso.PublicSsoTenantModel import io.tolgee.hateoas.userAccount.PrivateUserAccountModel class InitialDataModel( val serverConfiguration: PublicConfigurationDTO, + var authInfo: AuthInfoModel? = null, var userInfo: PrivateUserAccountModel? = null, var ssoInfo: PublicSsoTenantModel? = null, var preferredOrganization: PrivateOrganizationModel? = null, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt index e0f05d789a..51a89fa3e4 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt @@ -9,7 +9,6 @@ import io.tolgee.hateoas.organization.SimpleOrganizationModelAssembler import io.tolgee.hateoas.permission.ComputedPermissionModelAssembler import io.tolgee.hateoas.permission.PermissionModelAssembler import io.tolgee.model.Project_.translationProtection -import io.tolgee.model.UserAccount import io.tolgee.model.views.ProjectWithLanguagesView import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.AvatarService @@ -71,7 +70,7 @@ class ProjectModelAssembler( view.organizationRole, view.organizationOwner.basePermission, view.directPermission, - UserAccount.Role.USER, - ).getAdminPermissions(authenticationFacade.authenticatedUserOrNull?.role) + authenticationFacade.authenticatedUserOrNull?.role, + ) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt index 96b6e63b24..24838e5ac8 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt @@ -6,7 +6,6 @@ import io.tolgee.hateoas.language.LanguageModelAssembler import io.tolgee.hateoas.organization.SimpleOrganizationModelAssembler import io.tolgee.hateoas.permission.ComputedPermissionModelAssembler import io.tolgee.hateoas.permission.PermissionModelAssembler -import io.tolgee.model.UserAccount import io.tolgee.model.views.ProjectWithStatsView import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.AvatarService @@ -41,8 +40,8 @@ class ProjectWithStatsModelAssembler( view.organizationRole, view.organizationOwner.basePermission, view.directPermission, - UserAccount.Role.USER, - ).getAdminPermissions(userRole = authenticationFacade.authenticatedUserOrNull?.role) + authenticationFacade.authenticatedUserOrNull?.role, + ) return ProjectWithStatsModel( id = view.id, diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt index bfc847f69c..c656fc7370 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -18,6 +18,7 @@ package io.tolgee.configuration import io.tolgee.component.ExceptionHandlerFilter import io.tolgee.component.TransferEncodingHeaderDebugFilter +import io.tolgee.security.authentication.AdminAccessInterceptor import io.tolgee.security.authentication.AuthenticationFilter import io.tolgee.security.authentication.AuthenticationInterceptor import io.tolgee.security.authentication.EmailValidationInterceptor @@ -25,6 +26,7 @@ import io.tolgee.security.authentication.SsoAuthenticationInterceptor import io.tolgee.security.authorization.FeatureAuthorizationInterceptor import io.tolgee.security.authorization.OrganizationAuthorizationInterceptor import io.tolgee.security.authorization.ProjectAuthorizationInterceptor +import io.tolgee.security.authentication.ReadOnlyModeInterceptor import io.tolgee.security.ratelimit.GlobalIpRateLimitFilter import io.tolgee.security.ratelimit.GlobalUserRateLimitFilter import io.tolgee.security.ratelimit.RateLimitInterceptor @@ -36,7 +38,7 @@ import org.springframework.context.annotation.Lazy import org.springframework.core.Ordered import org.springframework.core.annotation.Order import org.springframework.security.config.Customizer -import org.springframework.security.config.annotation.ObjectPostProcessor +import org.springframework.security.config.ObjectPostProcessor import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy @@ -65,6 +67,10 @@ class WebSecurityConfig( @Lazy private val ssoAuthenticationInterceptor: SsoAuthenticationInterceptor, @Lazy + private val readOnlyModeInterceptor: ReadOnlyModeInterceptor, + @Lazy + private val adminAccessInterceptor: AdminAccessInterceptor, + @Lazy private val organizationAuthorizationInterceptor: OrganizationAuthorizationInterceptor, @Lazy private val projectAuthorizationInterceptor: ProjectAuthorizationInterceptor, @@ -94,7 +100,7 @@ class WebSecurityConfig( }, ) it.requestMatchers(*PUBLIC_ENDPOINTS).permitAll() - it.requestMatchers("/v2/administration/**", "/v2/ee-license/**").hasRole("ADMIN") + it.requestMatchers(*ADMIN_ENDPOINTS).hasRole("SUPPORTER") it.requestMatchers("/api/**", "/v2/**").authenticated() it.anyRequest().permitAll() }.headers { headers -> @@ -115,25 +121,26 @@ class WebSecurityConfig( fun internalSecurityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain { return httpSecurity .securityMatcher(*INTERNAL_ENDPOINTS) - .authorizeRequests() - .anyRequest() - .denyAll() - .and() + .authorizeHttpRequests { it.anyRequest().denyAll() } .build() } override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(rateLimitInterceptor) registry.addInterceptor(authenticationInterceptor) - registry.addInterceptor(emailValidationInterceptor) - .excludePathPatterns(*PUBLIC_ENDPOINTS, *INTERNAL_ENDPOINTS) registry.addInterceptor(ssoAuthenticationInterceptor) .excludePathPatterns(*PUBLIC_ENDPOINTS, *INTERNAL_ENDPOINTS) + registry.addInterceptor(emailValidationInterceptor) + .excludePathPatterns(*PUBLIC_ENDPOINTS, *INTERNAL_ENDPOINTS) registry.addInterceptor(organizationAuthorizationInterceptor) - .addPathPatterns("/v2/organizations/**") + .addPathPatterns(*ORGANIZATION_ENDPOINTS) registry.addInterceptor(projectAuthorizationInterceptor) - .addPathPatterns("/v2/projects/**", "/api/project/**", "/api/repository/**") + .addPathPatterns(*PROJECT_ENDPOINTS) + registry.addInterceptor(adminAccessInterceptor) + .addPathPatterns(*ADMIN_ENDPOINTS) + registry.addInterceptor(readOnlyModeInterceptor) + .excludePathPatterns(*PUBLIC_ENDPOINTS, *INTERNAL_ENDPOINTS) registry.addInterceptor(featureAuthorizationInterceptor) } @@ -156,6 +163,9 @@ class WebSecurityConfig( "/screenshots/**", "/uploaded-images/**", ) + private val ADMIN_ENDPOINTS = arrayOf("/v2/administration/**", "/v2/ee-license/**") private val INTERNAL_ENDPOINTS = arrayOf("/internal/**") + private val PROJECT_ENDPOINTS = arrayOf("/v2/projects/**", "/api/project/**", "/api/repository/**") + private val ORGANIZATION_ENDPOINTS = arrayOf("/v2/organizations/**") } } diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiConfiguration.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiConfiguration.kt index e1678bf851..6cf2fc1101 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiConfiguration.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiConfiguration.kt @@ -110,7 +110,7 @@ class OpenApiConfiguration { return OpenApiGroupBuilder(name) { builder.pathsToExclude(*excludedPaths, "/api/project/{$PROJECT_ID_PARAMETER}/sources/**") builder.pathsToMatch(*paths) - customizeOperations { operation, _, path -> + customizeOperations { operation, _, path, _ -> val isParameterConsumed = operation.isProjectIdConsumed() val pathContainsProjectId = path.contains("{$PROJECT_ID_PARAMETER}") @@ -168,7 +168,7 @@ class OpenApiConfiguration { return OpenApiGroupBuilder(name) { builder.pathsToExclude(*excludedPaths) .pathsToMatch(*paths) - customizeOperations { operation, handler, path -> + customizeOperations { operation, handler, path, _ -> if (isApiAccessAllowed(handler)) { if (!path.matches(PATH_WITH_PROJECT_ID_REGEX)) { if (!containsProjectIdParam(path)) { @@ -190,7 +190,7 @@ class OpenApiConfiguration { return OpenApiGroupBuilder(name) { builder.pathsToExclude(*excludedPaths) .pathsToMatch(*paths) - customizeOperations { operation, handler, path -> + customizeOperations { operation, handler, path, _ -> val isProjectPath = isProjectPath(path) val containsProjectIdParam = containsProjectIdParam(path) if (isProjectPath && !containsProjectIdParam) { diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt index f63e9c1178..6e6f2c81b8 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt @@ -9,6 +9,7 @@ import io.tolgee.openApiDocs.OpenApiOrderExtension import io.tolgee.openApiDocs.OpenApiSelfHostedExtension import io.tolgee.openApiDocs.OpenApiUnstableOperationExtension import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.isReadOnly import org.springdoc.core.models.GroupedOpenApi import org.springframework.web.method.HandlerMethod import java.lang.reflect.Method @@ -73,12 +74,13 @@ class OpenApiGroupBuilder( } private fun addMethodExtensions() { - customizeOperations { operation, handlerMethod, _ -> + customizeOperations { operation, handlerMethod, _, method -> addOperationOrder(handlerMethod, operation) addExtensionFor(handlerMethod, operation, OpenApiEeExtension::class.java, "x-ee") addExtensionFor(handlerMethod, operation, OpenApiCloudExtension::class.java, "x-cloud") addExtensionFor(handlerMethod, operation, OpenApiSelfHostedExtension::class.java, "x-self-hosted") addExtensionFor(handlerMethod, operation, OpenApiUnstableOperationExtension::class.java, "x-unstable") + addAccessModeExtension(handlerMethod, operation, method) operation } } @@ -110,9 +112,19 @@ class OpenApiGroupBuilder( operation.addExtension(extensionName, value?.invoke(annotation) ?: true) } + private fun addAccessModeExtension( + handlerMethod: HandlerMethod, + operation: Operation, + method: PathItem.HttpMethod, + ) { + val isReadOnly = handlerMethod.isReadOnly(httpMethod = method.name) + val accessMode = if (isReadOnly) "readOnly" else "readWrite" + operation.addExtension("x-access-mode", accessMode) + } + private fun addTagOrders() { val classTags = mutableMapOf, MutableSet>() - customizeOperations { operation, handlerMethod, path -> + customizeOperations { operation, handlerMethod, path, _ -> classTags.computeIfAbsent(handlerMethod.method.declaringClass) { mutableSetOf() }.apply { addAll(operation.tags) } @@ -165,20 +177,21 @@ class OpenApiGroupBuilder( /** * If null is returned from callback function, operation is removed from the API. */ - fun customizeOperations(fn: (Operation, HandlerMethod, path: String) -> Operation?) { + fun customizeOperations(fn: (Operation, HandlerMethod, path: String, method: PathItem.HttpMethod) -> Operation?) { builder.addOpenApiCustomizer { openApi -> val newPaths = Paths() openApi.paths.forEach { pathEntry -> val operations = ArrayList() val newPathItem = PathItem() val oldPathItem = pathEntry.value - oldPathItem.readOperations().forEach { operation -> + oldPathItem.readOperationsMap().forEach { (method, operation) -> val newOperation = fn( operation, operationHandlers[operation] ?: throw RuntimeException("Operation handler not found for ${operation.operationId}"), pathEntry.key, + method, ) if (newOperation != null) { operations.add(newOperation) @@ -202,7 +215,7 @@ class OpenApiGroupBuilder( } private fun setResponseContentToJson() { - customizeOperations { operation, _, _ -> + customizeOperations { operation, _, _, _ -> val successResponse = operation.responses?.get("200") val content = successResponse?.content val anyContent = content?.get("*/*") diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiSecurityHelper.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiSecurityHelper.kt index c3ebb764b5..2a367bcd44 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiSecurityHelper.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiSecurityHelper.kt @@ -17,7 +17,7 @@ class OpenApiSecurityHelper(private val groupBuilder: OpenApiGroupBuilder) { } private fun addSecurityToOperations() { - groupBuilder.customizeOperations { operation, handlerMethod, path -> + groupBuilder.customizeOperations { operation, handlerMethod, path, _ -> if (path.matches(OpenApiGroupBuilder.PUBLIC_ENDPOINT_REGEX)) { return@customizeOperations operation } diff --git a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt index a869a5f3e3..c63ebd24b0 100644 --- a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt @@ -257,14 +257,14 @@ class AuthTest : AbstractControllerTest() { @Test fun `super token endpoints require super token`() { val admin = userAccountService[initialUsername] - var token = jwtService.emitToken(admin.id, false) + var token = jwtService.emitToken(admin.id, isSuper = false) assertExpired(token) val baseline = Date() val newDate = baseline.time - tolgeeProperties.authentication.jwtSuperExpiration - 10_000 setForcedDate(Date(newDate)) - token = jwtService.emitToken(admin.id, true) + token = jwtService.emitToken(admin.id, isSuper = true) setForcedDate(baseline) assertExpired(token) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt index 16a6dea251..253266613e 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt @@ -246,7 +246,7 @@ class V2UserControllerTest : AuthorizedControllerTest() { ).andIsOk.andAssertThatJson { node("accessToken").isString.satisfies { token: String -> val authentication = jwtService.validateToken(token) - authentication.details?.isSuperToken == true + assertThat(authentication.isSuperToken).isTrue } } } @@ -264,7 +264,7 @@ class V2UserControllerTest : AuthorizedControllerTest() { ).andIsOk.andAssertThatJson { node("accessToken").isString.satisfies { token: String -> val authentication = jwtService.validateToken(token) - authentication.details?.isSuperToken == true + assertThat(authentication.isSuperToken).isTrue } } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationControllerTest.kt index cb454ed71d..93e4863d17 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationControllerTest.kt @@ -1,8 +1,10 @@ package io.tolgee.api.v2.controllers.administration import io.tolgee.development.testDataBuilder.data.AdministrationTestData +import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andGetContentAsString +import io.tolgee.fixtures.andIsCreated import io.tolgee.fixtures.andIsForbidden import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.andPrettyPrint @@ -87,6 +89,40 @@ class AdministrationControllerTest : AuthorizedControllerTest() { set("Authorization", "Bearer $token") }, ).andIsOk + + val org = OrganizationDto(name = "my lil organization 1") + performPost( + "/v2/organizations", + org, + HttpHeaders().apply { + set("Authorization", "Bearer $token") + } + ).andIsCreated + } + + @Test + fun `generates read-only user jwt token for supporter`() { + userAccount = testData.supporter + + val token = + performAuthGet("/v2/administration/users/${testData.user.id}/generate-token") + .andIsOk.andGetContentAsString + + performGet( + "/v2/organizations", + HttpHeaders().apply { + set("Authorization", "Bearer $token") + }, + ).andIsOk + + val org = OrganizationDto(name = "my lil organization 2") + performPost( + "/v2/organizations", + org, + HttpHeaders().apply { + set("Authorization", "Bearer $token") + } + ).andIsForbidden } @Test @@ -97,4 +133,19 @@ class AdministrationControllerTest : AuthorizedControllerTest() { performAuthPut("/v2/administration/users/${testData.user.id}/set-role/ADMIN", null).andIsForbidden performAuthGet("/v2/administration/users/${testData.user.id}/generate-token").andIsForbidden } + + @Test + fun `read only endpoints are allowed for supporter`() { + userAccount = testData.supporter + performAuthGet("/v2/administration/organizations").andIsOk + performAuthGet("/v2/administration/users").andIsOk + } + + @Test + fun `write endpoints are forbidden for supporter`() { + userAccount = testData.supporter + performAuthPut("/v2/administration/users/${testData.user.id}/set-role/ADMIN", null).andIsForbidden + performAuthPut("/v2/administration/users/${testData.user.id}/enable", null).andIsForbidden + performAuthPut("/v2/administration/users/${testData.user.id}/disable", null).andIsForbidden + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/security/ProjectApiKeyAuthenticationTest.kt b/backend/app/src/test/kotlin/io/tolgee/security/ProjectApiKeyAuthenticationTest.kt index d8363dd016..d2a69fd330 100644 --- a/backend/app/src/test/kotlin/io/tolgee/security/ProjectApiKeyAuthenticationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/security/ProjectApiKeyAuthenticationTest.kt @@ -143,7 +143,7 @@ class ProjectApiKeyAuthenticationTest : AbstractControllerTest() { ).andIsOk // Revoke user permissions - val tokenFrantisek = jwtService.emitToken(testData.frantisekDobrota.id, true) + val tokenFrantisek = jwtService.emitToken(testData.frantisekDobrota.id, isSuper = true) performPut( "/v2/projects/${testData.frantasProject.id}/users/${testData.user.id}/set-permissions/VIEW", null, diff --git a/backend/app/src/test/kotlin/io/tolgee/security/ServerAdminFilterTest.kt b/backend/app/src/test/kotlin/io/tolgee/security/ServerAdminFilterTest.kt index 9683be0eb7..31e6fc4a2d 100644 --- a/backend/app/src/test/kotlin/io/tolgee/security/ServerAdminFilterTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/security/ServerAdminFilterTest.kt @@ -15,6 +15,20 @@ class ServerAdminFilterTest : AuthorizedControllerTest() { performAuthGet("/v2/administration/organizations").andIsForbidden } + @Test + fun allowsAccessToServerSupporter() { + val serverAdmin = + userAccountService.createUser( + UserAccount( + username = "serverSupporter", + password = "admin", + role = UserAccount.Role.SUPPORTER, + ), + ) + loginAsUser(serverAdmin) + performAuthGet("/v2/administration/organizations").andIsOk + } + @Test fun allowsAccessToServerAdmin() { val serverAdmin = diff --git a/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt index 605caf474c..d6dbd263a9 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt @@ -153,9 +153,12 @@ class LanguageServiceTest : AbstractSpringTest() { private fun setAuthentication(user: UserAccount) { SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - UserAccountDto.fromEntity(user), - null, + credentials = null, + deviceId = null, + userAccount = UserAccountDto.fromEntity(user), + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ) } diff --git a/backend/app/src/test/kotlin/io/tolgee/service/dataImport/ImportServiceTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/dataImport/ImportServiceTest.kt index 2fb91a9a93..792ca28ec1 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/dataImport/ImportServiceTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/dataImport/ImportServiceTest.kt @@ -8,7 +8,6 @@ import io.tolgee.fixtures.waitForNotThrowing import io.tolgee.model.dataImport.Import import io.tolgee.model.dataImport.ImportTranslation import io.tolgee.security.authentication.TolgeeAuthentication -import io.tolgee.security.authentication.TolgeeAuthenticationDetails import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -122,9 +121,12 @@ class ImportServiceTest : AbstractSpringTest() { testDataService.saveTestData(testData.root) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - UserAccountDto.fromEntity(testData.userAccount), - TolgeeAuthenticationDetails(false), + credentials = null, + deviceId = null, + userAccount = UserAccountDto.fromEntity(testData.userAccount), + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ) testData } diff --git a/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt index 9ff605296f..307d4625b0 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt @@ -6,7 +6,6 @@ import io.tolgee.development.testDataBuilder.data.dataImport.ImportTestData import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.exceptions.BadRequestException import io.tolgee.security.authentication.TolgeeAuthentication -import io.tolgee.security.authentication.TolgeeAuthenticationDetails import io.tolgee.testing.assertions.Assertions.assertThat import org.assertj.core.api.Assertions import org.junit.jupiter.api.BeforeEach @@ -44,9 +43,12 @@ class StoredDataImporterTest : AbstractSpringTest() { fun login() { SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - UserAccountDto.fromEntity(importTestData.userAccount), - TolgeeAuthenticationDetails(false), + credentials = null, + deviceId = null, + userAccount = UserAccountDto.fromEntity(importTestData.userAccount), + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt b/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt index 2ba4ef86ee..2bb84fea19 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt @@ -6,4 +6,5 @@ enum class ComputedPermissionOrigin { ORGANIZATION_OWNER, NONE, SERVER_ADMIN, + SERVER_SUPPORTER, } diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 4821097e03..d792655277 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -307,8 +307,10 @@ enum class Message { SUGGESTION_CANT_BE_PLURAL, SUGGESTION_MUST_BE_PLURAL, DUPLICATE_SUGGESTION, - UNSUPPORTED_MEDIA_TYPE, + IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED, + ALREADY_IMPERSONATING_USER, + OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AdministrationTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AdministrationTestData.kt index d44ca44960..1bbd1f7583 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AdministrationTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/AdministrationTestData.kt @@ -6,6 +6,7 @@ import io.tolgee.model.UserAccount class AdministrationTestData { lateinit var admin: UserAccount + lateinit var supporter: UserAccount lateinit var user: UserAccount var adminBuilder: UserAccountBuilder @@ -19,6 +20,13 @@ class AdministrationTestData { admin = this } + addUserAccount { + username = "supporter@supporter.com" + name = "Matthew Supporter" + role = UserAccount.Role.SUPPORTER + supporter = this + } + addUserAccount { username = "user@user.com" name = "John User" diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt index 6dfc58c25e..186abf7b9d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt @@ -63,12 +63,28 @@ class ComputedPermissionDto( } } - val isAllPermitted = this.expandedScopes.toSet().containsAll(Scope.values().toList()) + val isAllPermitted = this.expandedScopes.toSet().containsAll(Scope.entries) - fun getAdminPermissions(userRole: UserAccount.Role?): ComputedPermissionDto { + /** + * Admin and Supporter users have some additional permissions on all projects compared to other users. + * This function adds all the additional permissions the user has the right to use based on their role. + */ + fun getAdminOrSupporterPermissions(userRole: UserAccount.Role?): ComputedPermissionDto { if (userRole == UserAccount.Role.ADMIN && !this.isAllPermitted) { return SERVER_ADMIN } + if (userRole == UserAccount.Role.SUPPORTER && !this.isAllReadOnlyPermitted) { + if (this.type == ProjectPermissionType.NONE && this.scopes.isEmpty()) { + // optimization - if a user doesn't have any permissions, + // we can return static override the same as we do for admin, + // otherwise we have to calculate permissions specific for them + return SERVER_SUPPORTER + } + return ComputedPermissionDto( + getExtendedPermission(this, arrayOf(Scope.ALL_VIEW)), + origin = ComputedPermissionOrigin.SERVER_SUPPORTER, + ) + } return this } @@ -109,6 +125,17 @@ class ComputedPermissionDto( } } + private fun getExtendedPermission(base: IPermission, extendedScopes: Array): IPermission { + return object : IPermission by base { + override val scopes: Array by lazy { + (base.scopes + extendedScopes).toSet().toTypedArray() + } + } + } + + val ComputedPermissionDto.isAllReadOnlyPermitted: Boolean + get() = expandedScopes.toSet().containsAll(Scope.readOnlyScopes.toList()) + val NONE get() = ComputedPermissionDto(getEmptyPermission(scopes = arrayOf(), ProjectPermissionType.NONE)) val ORGANIZATION_OWNER @@ -129,5 +156,14 @@ class ComputedPermissionDto( ), origin = ComputedPermissionOrigin.SERVER_ADMIN, ) + val SERVER_SUPPORTER + get() = + ComputedPermissionDto( + getEmptyPermission( + scopes = arrayOf(Scope.ALL_VIEW), + type = ProjectPermissionType.VIEW, + ), + origin = ComputedPermissionOrigin.SERVER_SUPPORTER, + ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt index a9f6724de1..9254d0ffc4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt @@ -43,3 +43,15 @@ data class UserAccountDto( return username } } + +fun UserAccountDto.isAdmin(): Boolean { + return role == UserAccount.Role.ADMIN +} + +fun UserAccountDto.isSupporter(): Boolean { + return role == UserAccount.Role.SUPPORTER +} + +fun UserAccountDto.isSupporterOrAdmin(): Boolean { + return role == UserAccount.Role.SUPPORTER || role == UserAccount.Role.ADMIN +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt index 606a98f6b6..a1317f28d7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -164,6 +164,7 @@ data class UserAccount( enum class Role { USER, ADMIN, + SUPPORTER, } enum class AccountType { @@ -175,3 +176,15 @@ data class UserAccount( @Transient override var disableActivityLogging: Boolean = false } + +fun UserAccount.isAdmin(): Boolean { + return role == UserAccount.Role.ADMIN +} + +fun UserAccount.isSupporter(): Boolean { + return role == UserAccount.Role.SUPPORTER +} + +fun UserAccount.isSupporterOrAdmin(): Boolean { + return role == UserAccount.Role.SUPPORTER || role == UserAccount.Role.ADMIN +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt index 0121c5222b..5f0c6ca3c3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt @@ -1,7 +1,9 @@ package io.tolgee.model.enums -enum class OrganizationRoleType { - MEMBER, - OWNER, - MAINTAINER, +enum class OrganizationRoleType( + val isReadOnly: Boolean +) { + MEMBER(true), + OWNER(false), + MAINTAINER(false), } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt index 48f972ea1c..f02fc05d8c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt @@ -84,12 +84,12 @@ enum class ProjectPermissionType(val availableScopes: Array) { fun getRoles(): Map> { val result = mutableMapOf>() - values().forEach { value -> result[value.name] = expandAvailableScopes(value) } + entries.forEach { value -> result[value.name] = expandAvailableScopes(value) } return result.toMap() } fun findByScope(scope: Scope): List { - return values().filter { expandAvailableScopes(it).contains(scope) } + return entries.filter { expandAvailableScopes(it).contains(scope) } } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt index 4915b763bd..210153f095 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonValue import io.tolgee.constants.Message import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException +import kotlin.arrayOf enum class Scope( @get:JsonValue @@ -42,16 +43,26 @@ enum class Scope( PROMPTS_EDIT("prompts.edit"), TRANSLATION_LABEL_MANAGE("translation-labels.manage"), TRANSLATION_LABEL_ASSIGN("translation-labels.assign"), + ALL_VIEW("all.view"), ; fun expand() = Scope.expand(this) + fun isReadOnly() = Scope.isReadOnly(this) + companion object { + + val readOnlyScopes by lazy { ALL_VIEW.expand() } + private val keysView = HierarchyItem(KEYS_VIEW) private val translationsView = HierarchyItem(TRANSLATIONS_VIEW, listOf(keysView)) private val screenshotsView = HierarchyItem(SCREENSHOTS_VIEW, listOf(keysView)) private val translationsEdit = HierarchyItem(TRANSLATIONS_EDIT, listOf(translationsView)) private val tasksView = HierarchyItem(TASKS_VIEW, listOf(translationsView)) + private val activityView = HierarchyItem(ACTIVITY_VIEW) + private val membersView = HierarchyItem(MEMBERS_VIEW) + private val batchJobsView = HierarchyItem(BATCH_JOBS_VIEW) + private val promptsView = HierarchyItem(PROMPTS_VIEW) val hierarchy = HierarchyItem( @@ -90,19 +101,19 @@ enum class Scope( HierarchyItem( PROMPTS_EDIT, listOf( - HierarchyItem(PROMPTS_VIEW), - HierarchyItem(PROJECT_EDIT), - HierarchyItem(LANGUAGES_EDIT), - translationsView, - screenshotsView - ) + promptsView, + HierarchyItem(PROJECT_EDIT), + HierarchyItem(LANGUAGES_EDIT), + translationsView, + screenshotsView + ) ), - HierarchyItem(ACTIVITY_VIEW), + activityView, HierarchyItem(LANGUAGES_EDIT, listOf(HierarchyItem(PROMPTS_VIEW))), HierarchyItem(PROJECT_EDIT), HierarchyItem( MEMBERS_EDIT, - listOf(HierarchyItem(MEMBERS_VIEW)), + listOf(membersView), ), HierarchyItem( TRANSLATIONS_COMMENTS_SET_STATE, @@ -132,12 +143,25 @@ enum class Scope( TRANSLATIONS_SUGGEST, listOf(translationsView), ), - HierarchyItem(BATCH_JOBS_VIEW), + batchJobsView, HierarchyItem(BATCH_JOBS_CANCEL), HierarchyItem(BATCH_PRE_TRANSLATE_BY_TM, listOf(translationsEdit)), HierarchyItem(BATCH_MACHINE_TRANSLATE, listOf(translationsEdit)), HierarchyItem(CONTENT_DELIVERY_MANAGE, listOf(HierarchyItem(CONTENT_DELIVERY_PUBLISH))), HierarchyItem(WEBHOOKS_MANAGE), + HierarchyItem( + ALL_VIEW, + listOf( + translationsView, + screenshotsView, + activityView, + membersView, + keysView, + batchJobsView, + tasksView, + promptsView, + ), + ), ), ) @@ -209,7 +233,7 @@ enum class Scope( } fun fromValue(value: String): Scope { - for (scope in values()) { + for (scope in entries) { if (scope.value == value) { return scope } @@ -220,12 +244,20 @@ enum class Scope( fun parse(scopes: Collection?): Set { scopes ?: return setOf() return scopes.map { stringScope -> - Scope.values().find { it.value == stringScope } ?: throw BadRequestException( + Scope.entries.find { it.value == stringScope } ?: throw BadRequestException( Message.SCOPE_NOT_FOUND, listOf(stringScope), ) }.toSet() } + + fun isReadOnly(scope: Scope): Boolean { + return readOnlyScopes.contains(scope) + } + + fun areAllReadOnly(scopes: Array?): Boolean { + return scopes?.all { isReadOnly(it) } ?: true + } } data class HierarchyItem(val scope: Scope, val requires: List = listOf()) diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index 31ba7f5989..80115e056a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -63,7 +63,7 @@ interface ProjectRepository : JpaRepository { where ( (p is not null and (p.type <> 'NONE' or p.type is null)) or (role is not null and (o.basePermission.type <> 'NONE' or o.basePermission.type is null) and p is null) or - (ua.role = 'ADMIN' and :organizationId is not null)) + ((ua.role = 'ADMIN' or ua.role = 'SUPPORTER') and :organizationId is not null)) and ( :search is null or (lower(r.name) like lower(concat('%', cast(:search as text), '%')) or lower(o.name) like lower(concat('%', cast(:search as text),'%'))) diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt index 20f34e480d..52a18b97d6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFacade.kt @@ -79,9 +79,18 @@ class AuthenticationFacade( return authentication.userAccountView } + // -- ACTING USER + val actingUser: UserAccountDto? + get() = authentication.actingAsUserAccount + // -- AUTHENTICATION METHOD AND DETAILS + val deviceId: String? + get() = authentication.deviceId + val isReadOnly: Boolean + get() = authentication.isReadOnly + val isUserSuperAuthenticated: Boolean - get() = if (isAuthenticated) authentication.details?.isSuperToken == true else false + get() = if (isAuthenticated) authentication.isSuperToken else false val isApiAuthentication: Boolean get() = isProjectApiKeyAuth || isPersonalAccessTokenAuth diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt index a1904f4603..f3db69cd0d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt @@ -28,8 +28,11 @@ import io.tolgee.component.CurrentDateProvider import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.dtos.cacheable.isAdmin +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.exceptions.AuthExpiredException import io.tolgee.exceptions.AuthenticationException +import io.tolgee.exceptions.PermissionException import io.tolgee.service.security.UserAccountService import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service @@ -43,6 +46,7 @@ class JwtService( private val authenticationProperties: AuthenticationProperties, private val currentDateProvider: CurrentDateProvider, private val userAccountService: UserAccountService, + private val authenticationFacade: AuthenticationFacade, ) { private val jwtParser: JwtParser = Jwts.parserBuilder() @@ -54,16 +58,20 @@ class JwtService( * Emits an authentication token for the given user. * * @param userAccountId The user account ID this token belongs to. + * @param actingAsUserAccountId The user account ID of the actor who initiated impersonation. + * @param isReadOnly Whether the token allows read-only access or full read-write access. * @param isSuper Whether to emit a super-powered token or not. * @return An authentication token. */ fun emitToken( userAccountId: Long, + actingAsUserAccountId: Long? = null, + isReadOnly: Boolean = false, isSuper: Boolean = false, ): String { val now = currentDateProvider.date - val expiration = Date(now.time + authenticationProperties.jwtExpiration) + val builder = Jwts.builder() .signWith(signingKey) @@ -72,6 +80,17 @@ class JwtService( .setSubject(userAccountId.toString()) .setExpiration(expiration) + if (actingAsUserAccountId != null) { + builder.claim(JWT_TOKEN_ACTING_USER_ID_CLAIM, actingAsUserAccountId.toString()) + } + + val deviceId = UUID.randomUUID().toString() + builder.claim(JWT_TOKEN_DEVICE_ID_CLAIM, deviceId) + + if (isReadOnly) { + builder.claim(JWT_TOKEN_READ_ONLY_CLAIM, true) + } + if (isSuper) { val superExpiration = Date(now.time + authenticationProperties.jwtSuperExpiration) builder.claim(SUPER_JWT_TOKEN_EXPIRATION_CLAIM, superExpiration) @@ -80,6 +99,55 @@ class JwtService( return builder.compact() } + /** + * Emits a refreshed authentication token for the currently authenticated user, propagating existing + * authentication flags if applicable. + * + * @param isSuper Whether to emit a super-powered token. Pass `null` to inherit the current + * authentication's super-token state. Defaults to `false`. + * @return A refreshed authentication token. + */ + fun emitTokenRefreshForCurrentUser( + isSuper: Boolean? = false, + ): String { + return emitToken( + userAccountId = authenticationFacade.authenticatedUser.id, + actingAsUserAccountId = authenticationFacade.actingUser?.id, + isReadOnly = authenticationFacade.isReadOnly, + isSuper = isSuper ?: authenticationFacade.isUserSuperAuthenticated, + ) + } + + fun emitAdminImpersonationToken(userAccountId: Long): String { + return emitToken( + userAccountId = userAccountId, + actingAsUserAccountId = authenticationFacade.authenticatedUser.id, + isReadOnly = false, + isSuper = true, + ) + } + + fun emitSupporterImpersonationToken(userAccountId: Long): String { + return emitToken( + userAccountId = userAccountId, + actingAsUserAccountId = authenticationFacade.authenticatedUser.id, + isReadOnly = true, + isSuper = false, + ) + } + + fun emitImpersonationToken(userAccountId: Long): String { + if (authenticationFacade.authenticatedUser.isAdmin()) { + return emitAdminImpersonationToken(userAccountId) + } + + if (authenticationFacade.authenticatedUser.isSupporterOrAdmin()) { + return emitSupporterImpersonationToken(userAccountId) + } + + throw PermissionException() + } + /** * Emits a ticket for a given user. * @@ -133,13 +201,37 @@ class JwtService( throw AuthExpiredException(Message.EXPIRED_JWT_TOKEN) } + val actor = validateActor(jws.body) + + // to avoid mass sign-out on update, we allow tokens without a device id + // maybe we should consider changing this later on + val deviceId = jws.body[JWT_TOKEN_DEVICE_ID_CLAIM] as? String + val steClaim = jws.body[SUPER_JWT_TOKEN_EXPIRATION_CLAIM] as? Long val hasSuperPowers = steClaim != null && steClaim > currentDateProvider.date.time + val roClaim = jws.body[JWT_TOKEN_READ_ONLY_CLAIM] as? Boolean ?: false + + if (roClaim && account.isAdmin()) { + // we don't allow admin accounts to be impersonated in read-only mode to make our lives easier + throw AuthenticationException(Message.INVALID_JWT_TOKEN) + } + + if (actor != null) { + val canImpersonate = actor.isAdmin() || (roClaim && actor.isSupporterOrAdmin()) + if (!canImpersonate) { + // actor got demoted and is no longer admin/supporter; impersonation not allowed + throw AuthenticationException(Message.INVALID_JWT_TOKEN) + } + } + return TolgeeAuthentication( - jws, - account, - TolgeeAuthenticationDetails(hasSuperPowers), + credentials = jws, + deviceId = deviceId, + userAccount = account, + actingAsUserAccount = actor, + isReadOnly = roClaim, + isSuperToken = hasSuperPowers, ) } @@ -190,6 +282,13 @@ class JwtService( return account } + private fun validateActor(claims: Claims): UserAccountDto? { + val actorId = claims[JWT_TOKEN_ACTING_USER_ID_CLAIM] as? String ?: return null + val account = userAccountService.findDto(actorId.toLong()) + ?: throw AuthenticationException(Message.USER_NOT_FOUND) + return account + } + private fun parseJwt(token: String): Jws { try { return jwtParser.parseClaimsJws(token) @@ -212,6 +311,9 @@ class JwtService( const val JWT_TICKET_TYPE_CLAIM = "t.typ" const val JWT_TICKET_DATA_CLAIM = "t.dat" const val SUPER_JWT_TOKEN_EXPIRATION_CLAIM = "ste" + const val JWT_TOKEN_ACTING_USER_ID_CLAIM = "act.sub" + const val JWT_TOKEN_READ_ONLY_CLAIM = "ro" + const val JWT_TOKEN_DEVICE_ID_CLAIM = "d.id" const val DEFAULT_TICKET_EXPIRATION_TIME = 5 * 60 * 1000L // 5 minutes } diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt index d237ed8683..2fe9833405 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt @@ -27,8 +27,25 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority class TolgeeAuthentication( private val credentials: Any?, + /** + * Device id - unique for each token. For activity logging. + */ + val deviceId: String?, private val userAccount: UserAccountDto, - private val details: TolgeeAuthenticationDetails?, + /** + * If this is an impersonation token, this property will contain + * the user account, which initiated the impersonation. + */ + val actingAsUserAccount: UserAccountDto?, + /** + * Whether the token can be used only for read-only requests. + */ + val isReadOnly: Boolean, + /** + * Whether the user is super-authenticated + */ + val isSuperToken: Boolean = false, + private val details: TolgeeAuthenticationDetails? = null, ) : Authentication { var userAccountEntity: UserAccount? = null var userAccountView: UserAccountView? = null @@ -41,16 +58,36 @@ class TolgeeAuthentication( override fun getAuthorities(): Collection { return when (userAccount.role) { - UserAccount.Role.USER -> listOf(SimpleGrantedAuthority(ROLE_USER)) + UserAccount.Role.USER -> + listOf( + SimpleGrantedAuthority(ROLE_USER), + ) + UserAccount.Role.SUPPORTER -> + listOf( + SimpleGrantedAuthority(ROLE_USER), + SimpleGrantedAuthority(ROLE_SUPPORTER), + ) UserAccount.Role.ADMIN -> listOf( SimpleGrantedAuthority(ROLE_USER), + SimpleGrantedAuthority(ROLE_SUPPORTER), SimpleGrantedAuthority(ROLE_ADMIN), ) null -> emptyList() - } + } + authorityFromIsReadOnly } + private val authorityFromIsReadOnly: GrantedAuthority + get() { + return SimpleGrantedAuthority( + if (isReadOnly) { + ROLE_READ_ONLY + } else { + ROLE_READ_WRITE + } + ) + } + override fun getCredentials(): Any? { return credentials } @@ -73,6 +110,9 @@ class TolgeeAuthentication( companion object { const val ROLE_USER = "ROLE_USER" + const val ROLE_SUPPORTER = "ROLE_SUPPORTER" const val ROLE_ADMIN = "ROLE_ADMIN" + const val ROLE_READ_ONLY = "ROLE_READ_ONLY" + const val ROLE_READ_WRITE = "ROLE_READ_WRITE" } } diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthenticationDetails.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthenticationDetails.kt index 15f08bae49..1cc553f09b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthenticationDetails.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthenticationDetails.kt @@ -16,9 +16,6 @@ package io.tolgee.security.authentication -data class TolgeeAuthenticationDetails( - /** - * Whether the user is super-authenticated - */ - val isSuperToken: Boolean, -) +import java.io.Serializable + +class TolgeeAuthenticationDetails : Serializable diff --git a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt index b664a7b6fd..9e03d59b69 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt @@ -14,7 +14,6 @@ import io.tolgee.model.UserAccount import io.tolgee.model.enums.Scope import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.TolgeeAuthentication -import io.tolgee.security.authentication.TolgeeAuthenticationDetails import io.tolgee.service.dataImport.ImportService import io.tolgee.service.project.ProjectCreationService import io.tolgee.service.project.ProjectService @@ -83,8 +82,11 @@ class StartupImportService( SecurityContextHolder.getContext().authentication = TolgeeAuthentication( credentials = null, + deviceId = null, userAccount = UserAccountDto.fromEntity(userAccount), - details = TolgeeAuthenticationDetails(false), + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt index e380d93a64..f6a58214a5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt @@ -4,6 +4,8 @@ import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.dtos.cacheable.UserOrganizationRoleDto +import io.tolgee.dtos.cacheable.isAdmin +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.dtos.request.organization.SetOrganizationRoleDto import io.tolgee.dtos.request.validators.exceptions.ValidationException import io.tolgee.exceptions.NotFoundException @@ -49,20 +51,18 @@ class OrganizationRoleService( fun checkUserCanView(organizationId: Long) { checkUserCanView( - authenticationFacade.authenticatedUser.id, + authenticationFacade.authenticatedUser, organizationId, - authenticationFacade.authenticatedUser.role == UserAccount.Role.ADMIN, ) } private fun checkUserCanView( - userId: Long, + user: UserAccountDto, organizationId: Long, - isAdmin: Boolean = false, ) { - if (!isAdmin && + if (!user.isSupporterOrAdmin() && !canUserViewStrict( - userId, + user.id, organizationId, ) ) { @@ -84,7 +84,7 @@ class OrganizationRoleService( fun canUserView( user: UserAccountDto, organizationId: Long, - ) = user.role === UserAccount.Role.ADMIN || this.organizationRepository.canUserView(user.id, organizationId) + ) = user.isSupporterOrAdmin() || this.organizationRepository.canUserView(user.id, organizationId) /** * Verifies the user has a role equal or higher than a given role. @@ -113,55 +113,46 @@ class OrganizationRoleService( } } - fun checkUserIsOwner( - userId: Long, - organizationId: Long, - ) { - val isServerAdmin = userAccountService.getDto(userId).role == UserAccount.Role.ADMIN - if (this.isUserOwner( - userId, - organizationId, - ) || isServerAdmin - ) { + fun checkUserIsOwnerOrServerAdmin(organizationId: Long) { + val user = authenticationFacade.authenticatedUser + if (this.isUserOwner(user.id, organizationId)) { return - } else { - throw PermissionException(Message.USER_IS_NOT_OWNER_OF_ORGANIZATION) } - } - fun checkUserIsOwnerOrMaintainer( - userId: Long, - organizationId: Long, - ) { - val isServerAdmin = userAccountService.getDto(userId).role == UserAccount.Role.ADMIN - if (this.isUserOwnerOrMaintainer( - userId, - organizationId, - ) || isServerAdmin - ) { + if (user.isAdmin()) { return - } else { - throw PermissionException(Message.USER_IS_NOT_OWNER_OR_MAINTAINER_OF_ORGANIZATION) } - } - fun checkUserIsOwner(organizationId: Long) { - this.checkUserIsOwner(authenticationFacade.authenticatedUser.id, organizationId) + throw PermissionException(Message.USER_IS_NOT_OWNER_OF_ORGANIZATION) } - fun checkUserIsOwnerOrMaintainer(organizationId: Long) { - this.checkUserIsOwnerOrMaintainer(authenticationFacade.authenticatedUser.id, organizationId) + fun checkUserCanDeleteInvitation( + organizationId: Long, + ) { + checkUserIsOwnerOrServerAdmin(organizationId) } - fun checkUserIsMember( - userId: Long, + fun checkUserCanTransferProjectToOrganization( organizationId: Long, ) { - val isServerAdmin = userAccountService.getDto(userId).role == UserAccount.Role.ADMIN - if (hasAnyOrganizationRole(userId, organizationId) || isServerAdmin) { + checkUserIsOwnerOrServerAdmin(organizationId) + } + + fun checkUserIsOwnerOrMaintainerOrServerAdmin(organizationId: Long) { + val user = authenticationFacade.authenticatedUser + if (this.isUserOwnerOrMaintainer(user.id, organizationId)) { return } - throw PermissionException(Message.USER_IS_NOT_MEMBER_OF_ORGANIZATION) + + if (user.isAdmin()) { + return + } + + throw PermissionException(Message.USER_IS_NOT_OWNER_OR_MAINTAINER_OF_ORGANIZATION) + } + + fun checkUserCanCreateProject(organizationId: Long) { + this.checkUserIsOwnerOrMaintainerOrServerAdmin(organizationId) } fun hasAnyOrganizationRole( diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt index 7b3fa6bdbc..0706bb30ea 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt @@ -17,6 +17,7 @@ import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.ThirdPartyAuthType +import io.tolgee.model.isAdmin import io.tolgee.repository.OrganizationRepository import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.AvatarService @@ -153,7 +154,7 @@ class OrganizationService( tolgeeProperties.authentication.userCanCreateOrganizations && userAccount.thirdPartyAuthType !== ThirdPartyAuthType.SSO - if (canCreateOrganizations || userAccount.role == UserAccount.Role.ADMIN) { + if (canCreateOrganizations || userAccount.isAdmin()) { return@let createPreferred(userAccount) } null @@ -346,12 +347,6 @@ class OrganizationService( */ fun getAllSingleOwnedByUser(userAccount: UserAccount) = organizationRepository.getAllSingleOwnedByUser(userAccount) - fun getOrganizationAndCheckUserIsOwner(organizationId: Long): Organization { - val organization = this.get(organizationId) - organizationRoleService.checkUserIsOwner(organization.id) - return organization - } - @Caching( evict = [ CacheEvict(cacheNames = [Caches.ORGANIZATIONS], key = "#organization.id"), diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt index 0331389bbf..1f9e1b668b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt @@ -38,7 +38,7 @@ class MfaService( throw PermissionException(Message.BAD_CREDENTIALS) } - if (user.totpKey?.isNotEmpty() == true) { + if (user.isMfaEnabled) { throw BadRequestException(Message.MFA_ENABLED) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt index 23d4840e1d..797f314a03 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt @@ -235,9 +235,7 @@ class PermissionService( else -> ComputedPermissionDto.NONE } - return userRole?.let { - computed.getAdminPermissions(userRole) - } ?: computed + return computed.getAdminOrSupporterPermissions(userRole) } fun createForInvitation( @@ -473,12 +471,10 @@ class PermissionService( } @Transactional - fun setOrganizationBasePermissions( + fun removeDirectProjectPermissions( projectId: Long, userId: Long, ) { - val project = projectService.get(projectId) - organizationRoleService.checkUserIsMember(userId, project.organizationOwner.id) val permission = getProjectPermissionData(projectId, userId).directPermissions ?: return delete(permission.id) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index e34aec1d3f..a1378486b0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -4,6 +4,8 @@ import io.tolgee.constants.Message import io.tolgee.dtos.ComputedPermissionDto import io.tolgee.dtos.cacheable.ApiKeyDto import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.dtos.cacheable.isAdmin +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.exceptions.LanguageNotPermittedException import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException @@ -51,7 +53,7 @@ class SecurityService( fun checkAnyProjectPermission(projectId: Long) { if ( getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() && - !isCurrentUserServerAdmin() + !activeUser.isSupporterOrAdmin() ) { throw PermissionException(Message.USER_HAS_NO_PROJECT_ACCESS) } @@ -154,7 +156,12 @@ class SecurityService( requiredScope: Scope, userAccountDto: UserAccountDto, ) { - if (isUserAdmin(userAccountDto)) { + if (userAccountDto.isAdmin()) { + return + } + + val isReadonlyAccess = requiredScope.isReadOnly() + if (isReadonlyAccess && userAccountDto.isSupporterOrAdmin()) { return } @@ -182,10 +189,12 @@ class SecurityService( languageTags: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) - checkLanguagePermissionByTag( - projectId, - languageTags, - ) { data, languageIds -> data.checkViewPermitted(*languageIds.toLongArray()) } + runIfUserNotServerSupporterOrAdmin { + checkLanguagePermissionByTag( + projectId, + languageTags, + ) { data, languageIds -> data.checkViewPermitted(*languageIds.toLongArray()) } + } } fun checkLanguageTranslatePermissionByTag( @@ -193,21 +202,12 @@ class SecurityService( languageTags: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) - checkLanguagePermissionByTag( - projectId, - languageTags, - ) { data, languageIds -> data.checkTranslatePermitted(*languageIds.toLongArray()) } - } - - fun checkStateEditPermissionByTag( - projectId: Long, - languageTags: Collection, - ) { - checkProjectPermission(projectId, Scope.TRANSLATIONS_STATE_EDIT) - checkLanguagePermissionByTag( - projectId, - languageTags, - ) { data, languageIds -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermissionByTag( + projectId, + languageTags, + ) { data, languageIds -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + } } fun checkLanguageSuggestPermission( @@ -215,9 +215,11 @@ class SecurityService( languageIds: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_SUGGEST) - checkLanguagePermission( - projectId, - ) { data -> data.checkSuggestPermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkSuggestPermitted(*languageIds.toLongArray()) } + } } fun checkLanguageViewPermission( @@ -225,9 +227,11 @@ class SecurityService( languageIds: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) - checkLanguagePermission( - projectId, - ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + runIfUserNotServerSupporterOrAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + } } private fun translationsInTask( @@ -236,10 +240,7 @@ class SecurityService( languageIds: Collection, keyId: Long? = null, ): Boolean { - checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) - checkLanguagePermission( - projectId, - ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + checkLanguageViewPermission(projectId, languageIds) if (keyId != null && languageIds.isNotEmpty()) { languageIds.forEach { @@ -261,9 +262,11 @@ class SecurityService( passIfAnyPermissionCheckSucceeds( { checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) - checkLanguagePermission( - projectId, - ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + } }, { if (!translationsInTask(projectId, TaskType.TRANSLATE, languageIds, keyId)) { @@ -292,9 +295,11 @@ class SecurityService( ) { try { checkProjectPermission(projectId, Scope.TRANSLATIONS_STATE_EDIT) - checkLanguagePermission( - projectId, - ) { data -> data.checkStateChangePermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkStateChangePermitted(*languageIds.toLongArray()) } + } } catch (e: PermissionException) { if (!translationsInTask(projectId, TaskType.REVIEW, languageIds, keyId)) { throw e @@ -334,9 +339,6 @@ class SecurityService( projectId: Long, permissionCheckFn: (data: ComputedPermissionDto) -> Unit, ) { - if (isCurrentUserServerAdmin()) { - return - } val usersPermission = permissionService.getProjectPermissionData( projectId, @@ -544,12 +546,16 @@ class SecurityService( } } - private fun isCurrentUserServerAdmin(): Boolean { - return isUserAdmin(activeUser) + private fun runIfUserNotServerAdmin(runnable: () -> Unit) { + if (!activeUser.isAdmin()) { + runnable() + } } - private fun isUserAdmin(user: UserAccountDto): Boolean { - return user.role == UserAccount.Role.ADMIN + private fun runIfUserNotServerSupporterOrAdmin(runnable: () -> Unit) { + if (!activeUser.isSupporterOrAdmin()) { + runnable() + } } private val activeUser: UserAccountDto diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt index c6ccd77798..a625e99a6f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt @@ -39,7 +39,7 @@ class SignUpService( emailVerificationService.createForUser(user, dto.callbackUrl) } - return JwtAuthenticationResponse(jwtService.emitToken(user.id, true)) + return JwtAuthenticationResponse(jwtService.emitToken(user.id, isSuper = true)) } private fun checkNotManagedByOrganization(domain: String?) { diff --git a/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt b/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt index a61b31a09c..1ac0d72f07 100644 --- a/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt @@ -21,6 +21,7 @@ import io.jsonwebtoken.security.Keys import io.tolgee.component.CurrentDateProvider import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.model.UserAccount import io.tolgee.exceptions.AuthenticationException import io.tolgee.service.security.UserAccountService import io.tolgee.testing.assertions.Assertions.assertThat @@ -40,6 +41,9 @@ class JwtServiceTest { const val SUPER_JWT_LIFETIME = 30 * 1000L // 30 seconds const val JWT_LIFETIME = 60 * 1000L // 60 seconds + + const val ADMIN_ACTOR_ID = 4242L + const val SUPPORTER_ACTOR_ID = 4343L } private val testSigningKey = Keys.secretKeyFor(SignatureAlgorithm.HS256) @@ -50,7 +54,11 @@ class JwtServiceTest { private val userAccountService = Mockito.mock(UserAccountService::class.java) + private val authenticationFacade = Mockito.mock(AuthenticationFacade::class.java) + private val userAccount = Mockito.mock(UserAccountDto::class.java) + private val adminActor = Mockito.mock(UserAccountDto::class.java) + private val supporterActor = Mockito.mock(UserAccountDto::class.java) private val jwtService: JwtService = JwtService( @@ -58,6 +66,7 @@ class JwtServiceTest { authenticationProperties, currentDateProvider, userAccountService, + authenticationFacade, ) @BeforeEach @@ -71,15 +80,26 @@ class JwtServiceTest { Mockito.`when`(userAccountService.findDto(Mockito.anyLong())).thenReturn(null) Mockito.`when`(userAccountService.findDto(TEST_USER_ID)).thenReturn(userAccount) + Mockito.`when`(userAccountService.findDto(ADMIN_ACTOR_ID)).thenReturn(adminActor) + Mockito.`when`(userAccountService.findDto(SUPPORTER_ACTOR_ID)).thenReturn(supporterActor) Mockito.`when`(userAccount.id).thenReturn(TEST_USER_ID) Mockito.`when`(userAccount.username).thenReturn(TEST_USER_EMAIL) Mockito.`when`(userAccount.tokensValidNotBefore).thenReturn(Date(now.time - USER_TOKENS_SINCE_RELATIVE)) + Mockito.`when`(adminActor.role).thenReturn(UserAccount.Role.USER) + + Mockito.`when`(adminActor.id).thenReturn(ADMIN_ACTOR_ID) + Mockito.`when`(adminActor.username).thenReturn("admin@tolgee.test") + Mockito.`when`(adminActor.role).thenReturn(UserAccount.Role.ADMIN) + + Mockito.`when`(supporterActor.id).thenReturn(SUPPORTER_ACTOR_ID) + Mockito.`when`(supporterActor.username).thenReturn("supporter@tolgee.test") + Mockito.`when`(supporterActor.role).thenReturn(UserAccount.Role.SUPPORTER) } @AfterEach fun resetMocks() { - Mockito.reset(currentDateProvider, userAccountService, userAccount) + Mockito.reset(authenticationProperties, currentDateProvider, userAccountService, authenticationFacade, userAccount) } @Test @@ -104,24 +124,24 @@ class JwtServiceTest { @Test fun `it stores the super powers of tokens when it has them`() { val token = jwtService.emitToken(TEST_USER_ID) - val superToken = jwtService.emitToken(TEST_USER_ID, true) + val superToken = jwtService.emitToken(TEST_USER_ID, isSuper = true) val auth = jwtService.validateToken(token) val superAuth = jwtService.validateToken(superToken) - assertThat(auth.details?.isSuperToken).isFalse() - assertThat(superAuth.details?.isSuperToken).isTrue() + assertThat(auth.isSuperToken).isFalse() + assertThat(superAuth.isSuperToken).isTrue() } @Test fun `it ignores super powers when they are expired`() { val now = currentDateProvider.date.time - val superToken = jwtService.emitToken(TEST_USER_ID, true) + val superToken = jwtService.emitToken(TEST_USER_ID, isSuper = true) Mockito.`when`(currentDateProvider.date).thenReturn(Date(now + SUPER_JWT_LIFETIME + 1000)) val superAuth = jwtService.validateToken(superToken) - assertThat(superAuth.details?.isSuperToken).isFalse() + assertThat(superAuth.isSuperToken).isFalse() } @Test @@ -213,4 +233,65 @@ class JwtServiceTest { assertThrows { jwtService.validateTicket(noSigToken, JwtService.TicketType.AUTH_MFA) } assertThrows { jwtService.validateTicket(token, JwtService.TicketType.AUTH_MFA) } } + + @Test + fun `it sets read-only flag in tokens`() { + val token = jwtService.emitToken(TEST_USER_ID, isReadOnly = true) + val auth = jwtService.validateToken(token) + + assertThat(auth.isReadOnly).isTrue() + assertThat(auth.actingAsUserAccount).isNull() + } + + @Test + fun `it carries actor information for admin actor`() { + val token = jwtService.emitToken(TEST_USER_ID, actingAsUserAccountId = ADMIN_ACTOR_ID) + val auth = jwtService.validateToken(token) + + assertThat(auth.isReadOnly).isFalse() + assertThat(auth.actingAsUserAccount).isNotNull + assertThat(auth.actingAsUserAccount!!.id).isEqualTo(ADMIN_ACTOR_ID) + } + + @Test + fun `it rejects read-only tokens for admin subject`() { + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + + val token = jwtService.emitToken(TEST_USER_ID, isReadOnly = true) + + assertThrows { jwtService.validateToken(token) } + } + + @Test + fun `it rejects supporter actor impersonation when not read-only`() { + val token = jwtService.emitToken(TEST_USER_ID, actingAsUserAccountId = SUPPORTER_ACTOR_ID, isReadOnly = false) + + assertThrows { jwtService.validateToken(token) } + } + + @Test + fun `it allows supporter actor impersonation when read-only`() { + val token = jwtService.emitToken(TEST_USER_ID, actingAsUserAccountId = SUPPORTER_ACTOR_ID, isReadOnly = true) + val auth = jwtService.validateToken(token) + + assertThat(auth.isReadOnly).isTrue() + assertThat(auth.actingAsUserAccount).isNotNull + assertThat(auth.actingAsUserAccount!!.id).isEqualTo(SUPPORTER_ACTOR_ID) + } + + @Test + fun `it refreshes token and propagates read-only, super and actor`() { + Mockito.`when`(authenticationFacade.authenticatedUser).thenReturn(userAccount) + Mockito.`when`(authenticationFacade.actingUser).thenReturn(supporterActor) + Mockito.`when`(authenticationFacade.isReadOnly).thenReturn(true) + Mockito.`when`(authenticationFacade.isUserSuperAuthenticated).thenReturn(false) + + val token = jwtService.emitTokenRefreshForCurrentUser(isSuper = null) + val auth = jwtService.validateToken(token) + + assertThat(auth.isReadOnly).isTrue() + assertThat(auth.actingAsUserAccount).isNotNull + assertThat(auth.actingAsUserAccount!!.id).isEqualTo(SUPPORTER_ACTOR_ID) + assertThat(auth.isSuperToken).isFalse() + } } diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt index 980bf078f4..3f79b0351c 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt @@ -26,14 +26,14 @@ class SensitiveOperationProtectionE2eDataController( val baseline = currentDateProvider.date currentDateProvider.forcedDate = Date(baseline.time - authenticationProperties.jwtSuperExpiration - 10_000) - val expiredToken = jwtService.emitToken(data.franta.id, true) + val expiredToken = jwtService.emitToken(data.franta.id, isSuper = true) currentDateProvider.forcedDate = null return mapOf( "frantasProjectId" to data.frantasProject.id, "pepasProjectId" to data.pepasProject.id, "frantaExpiredSuperJwt" to expiredToken, - "pepaExpiredSuperJwt" to jwtService.emitToken(data.pepa.id, false), + "pepaExpiredSuperJwt" to jwtService.emitToken(data.pepa.id, isSuper = false), ) } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt new file mode 100644 index 0000000000..c61eb70b4f --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt @@ -0,0 +1,62 @@ +/** + * Copyright (C) 2025 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.security.authentication + +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.isAdmin +import io.tolgee.dtos.cacheable.isSupporterOrAdmin +import io.tolgee.exceptions.PermissionException +import io.tolgee.security.authorization.AbstractAuthorizationInterceptor +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod + +/** + * Checks if a user has admin privileges. + * Blocks write requests when the user is only a supporter. + * Annotate class or method with [ReadOnlyOperation] or [WriteOperation] to override. + */ +@Component +class AdminAccessInterceptor( + private val authenticationFacade: AuthenticationFacade, +) : AbstractAuthorizationInterceptor(allowGlobalRoutes = false) { + override fun preHandleInternal( + request: HttpServletRequest, + response: HttpServletResponse, + handler: HandlerMethod, + ): Boolean { + if (!authenticationFacade.isAuthenticated) { + // If not authenticated, skip (let other interceptors handle) + return true + } + + val hasWriteAccess = authenticationFacade.authenticatedUser.isAdmin() + if (hasWriteAccess) { + // If not in read-only mode, allow + return true + } + + val hasReadAccess = authenticationFacade.authenticatedUser.isSupporterOrAdmin() + if (hasReadAccess && handler.isReadOnly(request.method)) { + // These methods should be read-only - safe to call + return true + } + + throw PermissionException(Message.OPERATION_NOT_PERMITTED) + } +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt index 4b2a8ae8fa..e88bab2749 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt @@ -115,9 +115,12 @@ class AuthenticationFilter( if (!authenticationProperties.enabled) { SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - initialUser, - TolgeeAuthenticationDetails(true), + credentials = null, + deviceId = null, + userAccount = initialUser, + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = true, ) } } @@ -163,9 +166,12 @@ class AuthenticationFilter( apiKeyService.updateLastUsedAsync(pak.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - pak, - userAccount, - TolgeeAuthenticationDetails(false), + credentials = pak, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ) } @@ -188,9 +194,12 @@ class AuthenticationFilter( patService.updateLastUsedAsync(pat.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - pat, - userAccount, - TolgeeAuthenticationDetails(false), + credentials = pat, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ) } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeExtension.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeExtension.kt new file mode 100644 index 0000000000..ec93fedfa9 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeExtension.kt @@ -0,0 +1,32 @@ +package io.tolgee.security.authentication + +import org.springframework.core.annotation.AnnotationUtils +import org.springframework.web.method.HandlerMethod + +val READ_ONLY_METHODS = arrayOf("GET", "HEAD", "OPTIONS") + +/** + * Determines if the target endpoint is read-only. Can be overridden by annotating the method with + * [ReadOnlyOperation] or [WriteOperation] annotation. + */ +fun HandlerMethod.isReadOnly(httpMethod: String): Boolean { + val forceReadOnly = AnnotationUtils.getAnnotation(method, ReadOnlyOperation::class.java) != null + val forceWrite = AnnotationUtils.getAnnotation(method, WriteOperation::class.java) != null + + if (forceReadOnly && forceWrite) { + // This doesn't make sense + throw RuntimeException( + "Both `@ReadOnlyOperation` and `@WriteOperation` have been set for this endpoint!", + ) + } + + if (forceWrite) { + return false + } + + if (forceReadOnly) { + return true + } + + return httpMethod.uppercase() in READ_ONLY_METHODS +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt new file mode 100644 index 0000000000..a0be3c8507 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2025 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.security.authentication + +import io.tolgee.constants.Message +import io.tolgee.exceptions.PermissionException +import io.tolgee.security.authorization.AbstractAuthorizationInterceptor +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod + +/** + * Blocks write requests when the current authentication is read-only. + * Annotate class or method with [ReadOnlyOperation] or [WriteOperation] to override. + */ +@Component +class ReadOnlyModeInterceptor( + private val authenticationFacade: AuthenticationFacade, +) : AbstractAuthorizationInterceptor(allowGlobalRoutes = false) { + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun preHandleInternal( + request: HttpServletRequest, + response: HttpServletResponse, + handler: HandlerMethod, + ): Boolean { + if (!authenticationFacade.isAuthenticated) { + // If not authenticated, skip (let other interceptors handle) + return true + } + + if (!authenticationFacade.isReadOnly) { + // If not in read-only mode, allow + return true + } + + if (handler.isReadOnly(request.method)) { + // These methods should be read-only - safe to call from read-only mode + return true + } + + logger.debug( + "Rejecting access for user#{} - Write operation is not allowed in read-only mode", + authenticationFacade.authenticatedUser.id, + ) + throw PermissionException(Message.OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE) + } +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyOperation.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyOperation.kt new file mode 100644 index 0000000000..fbd602c156 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyOperation.kt @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2025 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.security.authentication + +/** + * Overrides read-only request method restriction for the annotated handler method. + * + * When current authentication has readOnly flag set, only GET/HEAD/OPTIONS HTTP methods are allowed by default. + * Applying this annotation to a handler method allows them to be called in read-only mode as well. + * + * This also applies to administration endpoint handling. Supporter role has only access to GET/HEAD/OPTIONS methods. + * Applying this annotation to a handler method allows them to be called by supporters as well. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ReadOnlyOperation diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/WriteOperation.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/WriteOperation.kt new file mode 100644 index 0000000000..1d8a7512e9 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/WriteOperation.kt @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2025 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.security.authentication + +import org.springframework.security.access.prepost.PreAuthorize + +/** + * Marks a component method that requires read-write mode (i.e. current authentication must not be read-only). + * + * This annotation also works as an override to administration endpoint handling. + * Applying this annotation to a handler method forbids it from being called by supporters. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@PreAuthorize("hasRole('READ_WRITE')") +annotation class WriteOperation diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt index 2661997599..ce4a734e79 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt @@ -23,8 +23,11 @@ import org.springframework.core.Ordered import org.springframework.core.annotation.AnnotationUtils import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor +import kotlin.jvm.java -abstract class AbstractAuthorizationInterceptor : HandlerInterceptor, Ordered { +abstract class AbstractAuthorizationInterceptor( + val allowGlobalRoutes: Boolean = true, +) : HandlerInterceptor, Ordered { override fun preHandle( request: HttpServletRequest, response: HttpServletResponse, @@ -40,7 +43,7 @@ abstract class AbstractAuthorizationInterceptor : HandlerInterceptor, Ordered { } // Global route; abort here - if (isGlobal(handler)) return true + if (allowGlobalRoutes && isGlobal(handler)) return true return preHandleInternal(request, response, handler) } @@ -55,7 +58,7 @@ abstract class AbstractAuthorizationInterceptor : HandlerInterceptor, Ordered { handler: HandlerMethod, ): Boolean - private fun isGlobal(handler: HandlerMethod): Boolean { + fun isGlobal(handler: HandlerMethod): Boolean { val annotation = AnnotationUtils.getAnnotation(handler.method, IsGlobalRoute::class.java) return annotation != null } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/FeatureAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/FeatureAuthorizationInterceptor.kt index d9101c0dcf..d0f986fdc2 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/FeatureAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/FeatureAuthorizationInterceptor.kt @@ -21,7 +21,7 @@ import org.springframework.web.method.HandlerMethod class FeatureAuthorizationInterceptor( private val enabledFeaturesProvider: EnabledFeaturesProvider, private val organizationHolder: OrganizationHolder, -) : AbstractAuthorizationInterceptor() { +) : AbstractAuthorizationInterceptor(allowGlobalRoutes = false) { private val logger = LoggerFactory.getLogger(this::class.java) override fun preHandleInternal( @@ -59,29 +59,29 @@ class FeatureAuthorizationInterceptor( organizationId: Long, features: List, ) { - for (feature in features) { - if (!enabledFeaturesProvider.isFeatureEnabled(organizationId, feature)) { - logger.debug( - "Feature {} is not enabled for org#{}", - feature, - organizationId, - ) - throw BadRequestException(Message.FEATURE_NOT_ENABLED, listOf(feature)) - } - } + val missing = features.filter { !enabledFeaturesProvider.isFeatureEnabled(organizationId, it) } + if (missing.isEmpty()) return + + logger.debug( + "Rejecting request for org#{} - Features {} are not enabled", + organizationId, + missing, + ) + throw BadRequestException(Message.FEATURE_NOT_ENABLED, missing) } private fun checkOneOfFeaturesEnabled( organizationId: Long, features: List, ) { - if (features.find { enabledFeaturesProvider.isFeatureEnabled(organizationId, it) } == null) { - logger.debug( - "None of the features {} are enabled for org#{}", - features, - organizationId, - ) - throw BadRequestException(Message.FEATURE_NOT_ENABLED, features) - } + val anyEnabled = features.any { enabledFeaturesProvider.isFeatureEnabled(organizationId, it) } + if (anyEnabled) return + + logger.debug( + "Rejecting request for org#{} - None of the features {} are enabled", + organizationId, + features, + ) + throw BadRequestException(Message.FEATURE_NOT_ENABLED, features) } } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt index 8b7fabd2e8..73522b37d4 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt @@ -16,13 +16,15 @@ package io.tolgee.security.authorization +import io.tolgee.dtos.cacheable.isAdmin +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException -import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.OrganizationHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authentication.isReadOnly import io.tolgee.service.organization.OrganizationRoleService import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -33,7 +35,7 @@ import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod /** - * This interceptor performs authorization step to access organization-related endpoints. + * This interceptor performs an authorization step to access organization-related endpoints. * By default, the user needs to have access to at least 1 project on the target org to access it. */ @Component @@ -56,12 +58,11 @@ class OrganizationAuthorizationInterceptor( val organization = requestContextService.getTargetOrganization(request) // Two possible scenarios: we're on `GET/POST /v2/organization`, or the organization was not found. - // In both cases, there is no authorization to perform and we simply continue. + // In both cases, there is no authorization to perform, and we simply continue. // It is not the job of the interceptor to return a 404 error. ?: return true var bypassed = false - val isAdmin = authenticationFacade.authenticatedUser.role == UserAccount.Role.ADMIN val requiredRole = getRequiredRole(request, handler) logger.debug( "Checking access to org#{} by user#{} (Requires {})", @@ -71,22 +72,27 @@ class OrganizationAuthorizationInterceptor( ) if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { - if (!isAdmin) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to org#{} for user#{} - No view permissions", organization.id, userId, ) - // Security consideration: if the user cannot see the organization, pretend it does not exist. - throw NotFoundException() + if (!canBypassForReadOnly()) { + // Security consideration: if the user cannot see the organization, pretend it does not exist. + throw NotFoundException() + } + + // Admin access for read-only operations is allowed, but it's not enough for the current operation. + throw PermissionException() } bypassed = true } if (requiredRole != null && !organizationRoleService.isUserOfRole(userId, organization.id, requiredRole)) { - if (!isAdmin) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to org#{} for user#{} - Insufficient role", organization.id, @@ -134,4 +140,20 @@ class OrganizationAuthorizationInterceptor( return orgPermission?.role } + + private fun canBypass( + request: HttpServletRequest, + handler: HandlerMethod, + ): Boolean { + if (authenticationFacade.authenticatedUser.isAdmin()) { + return true + } + + val forReadOnly = handler.isReadOnly(request.method) + return forReadOnly && canBypassForReadOnly() + } + + private fun canBypassForReadOnly(): Boolean { + return authenticationFacade.authenticatedUser.isSupporterOrAdmin() + } } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt index 40c322c04e..b4a64ecfd5 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt @@ -18,15 +18,17 @@ package io.tolgee.security.authorization import io.tolgee.activity.ActivityHolder import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.isAdmin +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException import io.tolgee.exceptions.ProjectNotFoundException -import io.tolgee.model.UserAccount import io.tolgee.model.enums.Scope import io.tolgee.security.OrganizationHolder import io.tolgee.security.ProjectHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authentication.isReadOnly import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.SecurityService import jakarta.servlet.http.HttpServletRequest @@ -37,7 +39,7 @@ import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod /** - * This interceptor performs authorization step to perform operations on a project or organization. + * This interceptor performs an authorization step to perform operations on a project or organization. */ @Component class ProjectAuthorizationInterceptor( @@ -65,7 +67,6 @@ class ProjectAuthorizationInterceptor( ?: return true var bypassed = false - val isAdmin = authenticationFacade.authenticatedUser.role == UserAccount.Role.ADMIN val requiredScopes = getRequiredScopes(request, handler) val formattedRequirements = requiredScopes?.joinToString(", ") { it.value } ?: "read-only" @@ -74,24 +75,29 @@ class ProjectAuthorizationInterceptor( val scopes = securityService.getCurrentPermittedScopes(project.id) if (scopes.isEmpty()) { - if (!isAdmin) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to proj#{} for user#{} - No view permissions", project.id, userId, ) - // Security consideration: if the user cannot see the project, pretend it does not exist. - throw ProjectNotFoundException(project.id) + if (!canBypassForReadOnly()) { + // Security consideration: if the user cannot see the project, pretend it does not exist. + throw ProjectNotFoundException(project.id) + } + + // Admin access for read-only operations is allowed, but it's not enough for the current operation. + throw PermissionException() } bypassed = true } - val missingScopes = getMissingScopes(requiredScopes, scopes.toSet()) + val missingScopes = getMissingScopes(requiredScopes, scopes) if (missingScopes.isNotEmpty()) { - if (!isAdmin || authenticationFacade.isProjectApiKeyAuth) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to proj#{} for user#{} - Insufficient permissions", project.id, @@ -177,4 +183,27 @@ class ProjectAuthorizationInterceptor( return projectPerms?.scopes } + + private val canUseAdminPermissions + get() = !authenticationFacade.isProjectApiKeyAuth + + private fun canBypass( + request: HttpServletRequest, + handler: HandlerMethod, + ): Boolean { + if (!canUseAdminPermissions) { + return false + } + + if (authenticationFacade.authenticatedUser.isAdmin()) { + return true + } + + val forReadOnly = handler.isReadOnly(request.method) + return forReadOnly && canBypassForReadOnly() + } + + private fun canBypassForReadOnly(): Boolean { + return canUseAdminPermissions && authenticationFacade.authenticatedUser.isSupporterOrAdmin() + } } diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AdminAccessInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AdminAccessInterceptorTest.kt new file mode 100644 index 0000000000..14db7826d5 --- /dev/null +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AdminAccessInterceptorTest.kt @@ -0,0 +1,262 @@ +/** + * Copyright (C) 2025 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.security.authentication + +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.UserAccount +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.whenever +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RestController + +class AdminAccessInterceptorTest { + private val authenticationFacade = Mockito.mock(AuthenticationFacade::class.java) + + private val authentication = Mockito.mock(TolgeeAuthentication::class.java) + + private val userAccount = Mockito.mock(UserAccountDto::class.java) + + private val interceptor = AdminAccessInterceptor(authenticationFacade) + + private val mockMvc = + MockMvcBuilders.standaloneSetup(TestController::class.java) + .addInterceptors(interceptor) + .build() + + @BeforeEach + fun setupMocks() { + whenever(authenticationFacade.authentication).thenReturn(authentication) + whenever(authenticationFacade.isAuthenticated).thenReturn(true) + whenever(authenticationFacade.authenticatedUser).thenReturn(userAccount) + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + } + + @AfterEach + fun resetMocks() { + Mockito.reset(authenticationFacade, authentication, userAccount) + } + + @Test + fun `it allows unauthenticated requests`() { + whenever(authenticationFacade.authentication).thenReturn(null) + whenever(authenticationFacade.isAuthenticated).thenReturn(false) + + mockMvc.perform(get("/admin/read")).andIsOk + mockMvc.perform(post("/admin/write")).andIsOk + mockMvc.perform(put("/admin/write")).andIsOk + mockMvc.perform(patch("/admin/write")).andIsOk + mockMvc.perform(delete("/admin/write")).andIsOk + } + + @Test + fun `it allows GET from admin`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + mockMvc.perform(get("/admin/read")).andIsOk + } + + @Test + fun `it allows HEAD from admin`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + mockMvc.perform(head("/admin/read")).andIsOk + } + + @Test + fun `it allows POST from admin`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + mockMvc.perform(post("/admin/write")).andIsOk + } + + @Test + fun `it allows PUT from admin`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + mockMvc.perform(put("/admin/write")).andIsOk + } + + @Test + fun `it allows PATCH from admin`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + mockMvc.perform(patch("/admin/write")).andIsOk + } + + @Test + fun `it allows DELETE from admin`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + mockMvc.perform(delete("/admin/write")).andIsOk + } + + @Test + fun `it allows GET from supporter`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(get("/admin/read")).andIsOk + } + + @Test + fun `it allows HEAD from supporter`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(head("/admin/read")).andIsOk + } + + @Test + fun `it denies POST from supporter`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(post("/admin/write")).andIsForbidden + } + + @Test + fun `it denies PUT from supporter`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(put("/admin/write")).andIsForbidden + } + + @Test + fun `it denies PATCH from supporter`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(patch("/admin/write")).andIsForbidden + } + + @Test + fun `it denies DELETE from supporter`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(delete("/admin/write")).andIsForbidden + } + + @Test + fun `it allows POST from supporter when method annotated with AllowInReadOnlyMode`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(post("/admin/write-allowed")).andIsOk + } + + @Test + fun `it allows PUT from supporter when method annotated with AllowInReadOnlyMode`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(put("/admin/write-allowed")).andIsOk + } + + @Test + fun `it allows PATCH from supporter when method annotated with AllowInReadOnlyMode`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(patch("/admin/write-allowed")).andIsOk + } + + @Test + fun `it allows DELETE from supporter when method annotated with AllowInReadOnlyMode`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(delete("/admin/write-allowed")).andIsOk + } + + @Test + fun `it denies GET from supporter when method annotated with WriteOperation`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(get("/admin/read-requires-rw")).andIsForbidden + } + + @Test + fun `it denies HEAD from supporter when method annotated with WriteOperation`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + mockMvc.perform(head("/admin/read-requires-rw")).andIsForbidden + } + + @Test + fun `it denies GET from user`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + mockMvc.perform(get("/admin/read")).andIsForbidden + } + + @Test + fun `it denies HEAD from user`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + mockMvc.perform(head("/admin/read")).andIsForbidden + } + + @Test + fun `it denies POST from user`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + mockMvc.perform(post("/admin/write")).andIsForbidden + } + + @Test + fun `it denies PUT from user`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + mockMvc.perform(put("/admin/write")).andIsForbidden + } + + @Test + fun `it denies PATCH from user`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + mockMvc.perform(patch("/admin/write")).andIsForbidden + } + + @Test + fun `it denies DELETE from user`() { + whenever(userAccount.role).thenReturn(UserAccount.Role.USER) + mockMvc.perform(delete("/admin/write")).andIsForbidden + } + + @RestController + class TestController { + @GetMapping("/admin/read") + fun read() = "ok" + + @PostMapping("/admin/write") + fun write() = "ok" + + @PutMapping("/admin/write") + fun put() = "ok" + + @PatchMapping("/admin/write") + fun patch() = "ok" + + @DeleteMapping("/admin/write") + fun delete() = "ok" + + @WriteOperation + @GetMapping("/admin/read-requires-rw") + fun readRequiresRw() = "ok" + + @ReadOnlyOperation + @PostMapping("/admin/write-allowed") + fun writeAllowed() = "ok" + + @ReadOnlyOperation + @PutMapping("/admin/write-allowed") + fun putAllowed() = "ok" + + @ReadOnlyOperation + @PatchMapping("/admin/write-allowed") + fun patchAllowed() = "ok" + + @ReadOnlyOperation + @DeleteMapping("/admin/write-allowed") + fun deleteAllowed() = "ok" + } +} diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt index 618d76b45b..bcef080d67 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt @@ -132,9 +132,12 @@ class AuthenticationFilterTest { Mockito.`when`(jwtService.validateToken(TEST_VALID_TOKEN)) .thenReturn( TolgeeAuthentication( - "uwu", - userAccountDto, - null, + credentials = "uwu", + deviceId = null, + userAccount = userAccountDto, + actingAsUserAccount = null, + isReadOnly = false, + isSuperToken = false, ), ) diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt new file mode 100644 index 0000000000..d807cfff04 --- /dev/null +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt @@ -0,0 +1,203 @@ +/** + * Copyright (C) 2025 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.tolgee.security.authentication + +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.whenever +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RestController + +class ReadOnlyModeInterceptorTest { + private val authenticationFacade = Mockito.mock(AuthenticationFacade::class.java) + + private val authentication = Mockito.mock(TolgeeAuthentication::class.java) + + private val interceptor = ReadOnlyModeInterceptor(authenticationFacade) + + private val mockMvc = + MockMvcBuilders.standaloneSetup(TestController::class.java) + .addInterceptors(interceptor) + .build() + + @BeforeEach + fun setupMocks() { + whenever(authenticationFacade.authenticatedUser).thenReturn(Mockito.mock(UserAccountDto::class.java)) + whenever(authenticationFacade.authentication).thenReturn(authentication) + whenever(authenticationFacade.isAuthenticated).thenReturn(true) + whenever(authenticationFacade.isReadOnly).thenCallRealMethod() + whenever(authentication.isReadOnly).thenReturn(true) + } + + @AfterEach + fun resetMocks() { + Mockito.reset(authenticationFacade, authentication) + } + + @Test + fun `it allows unauthenticated requests`() { + Mockito.reset(authenticationFacade) + whenever(authenticationFacade.authentication).thenReturn(null) + whenever(authenticationFacade.isAuthenticated).thenReturn(false) + whenever(authenticationFacade.isReadOnly).thenThrow(IllegalStateException("Should not be called")) + + mockMvc.perform(get("/test/read")).andIsOk + mockMvc.perform(head("/test/read")).andIsOk + mockMvc.perform(post("/test/write")).andIsOk + mockMvc.perform(put("/test/write")).andIsOk + mockMvc.perform(patch("/test/write")).andIsOk + mockMvc.perform(delete("/test/write")).andIsOk + } + + @Test + fun `it allows GET`() { + mockMvc.perform(get("/test/read")).andIsOk + } + + @Test + fun `it allows HEAD`() { + mockMvc.perform(head("/test/read")).andIsOk + } + + @Test + fun `it denies POST`() { + mockMvc.perform(post("/test/write")).andIsForbidden + } + + @Test + fun `it denies PUT`() { + mockMvc.perform(put("/test/write")).andIsForbidden + } + + @Test + fun `it denies PATCH`() { + mockMvc.perform(patch("/test/write")).andIsForbidden + } + + @Test + fun `it denies DELETE`() { + mockMvc.perform(delete("/test/write")).andIsForbidden + } + + @Test + fun `it allows read-only POST when method annotated with AllowInReadOnlyMode`() { + mockMvc.perform(post("/test/write-allowed")).andIsOk + } + + @Test + fun `it allows read-only PUT when method annotated with AllowInReadOnlyMode`() { + mockMvc.perform(put("/test/write-allowed")).andIsOk + } + + @Test + fun `it allows read-only PATCH when method annotated with AllowInReadOnlyMode`() { + mockMvc.perform(patch("/test/write-allowed")).andIsOk + } + + @Test + fun `it allows read-only DELETE when method annotated with AllowInReadOnlyMode`() { + mockMvc.perform(delete("/test/write-allowed")).andIsOk + } + + @Test + fun `it denies GET annotated with WriteOperation`() { + mockMvc.perform(get("/test/read-requires-rw")).andIsForbidden + } + + @Test + fun `it denies HEAD annotated with WriteOperation`() { + mockMvc.perform(head("/test/read-requires-rw")).andIsForbidden + } + + @Test + fun `it allows POST when we are not in read only mode`() { + whenever(authentication.isReadOnly).thenReturn(false) + mockMvc.perform(post("/test/write")).andIsOk + } + + @Test + fun `it allows PUT when we are not in read only mode`() { + whenever(authentication.isReadOnly).thenReturn(false) + mockMvc.perform(put("/test/write")).andIsOk + } + + @Test + fun `it allows PATCH when we are not in read only mode`() { + whenever(authentication.isReadOnly).thenReturn(false) + mockMvc.perform(patch("/test/write")).andIsOk + } + + @Test + fun `it allows DELETE when we are not in read only mode`() { + whenever(authentication.isReadOnly).thenReturn(false) + mockMvc.perform(delete("/test/write")).andIsOk + } + + @RestController + class TestController { + @GetMapping("/test/read") + fun read() = "ok" + + @PostMapping("/test/write") + fun write() = "ok" + + @PutMapping("/test/write") + fun put() = "ok" + + @PatchMapping("/test/write") + fun patch() = "ok" + + @DeleteMapping("/test/write") + fun delete() = "ok" + + @ReadOnlyOperation + @PostMapping("/test/write-allowed") + fun writeAllowed() = "ok" + + @ReadOnlyOperation + @PutMapping("/test/write-allowed") + fun putAllowed() = "ok" + + @ReadOnlyOperation + @PatchMapping("/test/write-allowed") + fun patchAllowed() = "ok" + + @ReadOnlyOperation + @DeleteMapping("/test/write-allowed") + fun deleteAllowed() = "ok" + + @WriteOperation + @GetMapping("/test/read-requires-rw") + fun readRequiresRw() = "ok" + } +} diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt index 05a7dcc0cf..2fe305f3fa 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt @@ -21,10 +21,12 @@ import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.fixtures.andIsForbidden import io.tolgee.fixtures.andIsNotFound import io.tolgee.fixtures.andIsOk +import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.OrganizationHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authentication.TolgeeAuthentication import io.tolgee.service.organization.OrganizationRoleService import org.junit.jupiter.api.AfterEach @@ -34,14 +36,20 @@ import org.junit.jupiter.api.assertThrows import org.mockito.Mockito import org.mockito.kotlin.any import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import io.tolgee.security.authentication.WriteOperation +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RestController class OrganizationAuthorizationInterceptorTest { private val authenticationFacade = Mockito.mock(AuthenticationFacade::class.java) + private val authentication = Mockito.mock(TolgeeAuthentication::class.java) + private val organizationRoleService = Mockito.mock(OrganizationRoleService::class.java) private val requestContextService = Mockito.mock(RequestContextService::class.java) @@ -65,13 +73,17 @@ class OrganizationAuthorizationInterceptorTest { @BeforeEach fun setupMocks() { - Mockito.`when`(authenticationFacade.authentication).thenReturn(Mockito.mock(TolgeeAuthentication::class.java)) + Mockito.`when`(authenticationFacade.authentication).thenReturn(authentication) Mockito.`when`(authenticationFacade.authenticatedUser).thenReturn(userAccount) Mockito.`when`(authenticationFacade.isApiAuthentication).thenReturn(false) Mockito.`when`(authenticationFacade.isUserSuperAuthenticated).thenReturn(false) + Mockito.`when`(authenticationFacade.isReadOnly).thenCallRealMethod() + + Mockito.`when`(authentication.isReadOnly).thenReturn(false) Mockito.`when`(requestContextService.getTargetOrganization(any())).thenReturn(organization) + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.USER) Mockito.`when`(userAccount.id).thenReturn(1337L) Mockito.`when`(organization.id).thenReturn(1337L) } @@ -80,6 +92,7 @@ class OrganizationAuthorizationInterceptorTest { fun resetMocks() { Mockito.reset( authenticationFacade, + authentication, organizationRoleService, requestContextService, organization, @@ -109,7 +122,7 @@ class OrganizationAuthorizationInterceptorTest { .thenReturn(false) mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsNotFound - mockMvc.perform(get("/v2/organizations/1337/requires-admin")).andIsNotFound + mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsNotFound Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) .thenReturn(true) @@ -124,12 +137,61 @@ class OrganizationAuthorizationInterceptorTest { Mockito.`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)) .thenReturn(false) - mockMvc.perform(get("/v2/organizations/1337/requires-admin")).andIsForbidden + mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsForbidden Mockito.`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)) .thenReturn(true) - mockMvc.perform(get("/v2/organizations/1337/requires-admin")).andIsOk + mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsOk + } + + @Test + fun `it allows supporter to bypass checks for read-only organization endpoints`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + + performReadOnlyRequests { all -> all.andIsOk } + } + + @Test + fun `it does not let supporter bypass checks for write organization endpoints`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + + performWriteRequests { all -> all.andIsForbidden } + } + + @Test + fun `it allows admin to access any endpoint`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + + performReadOnlyRequests { all -> all.andIsOk } + performWriteRequests { all -> all.andIsOk } + } + + private fun performReadOnlyRequests(condition: (ResultActions) -> Unit) { + // GET method + mockMvc.perform(get("/v2/organizations/1337/default-perms")).andSatisfies(condition) + mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andSatisfies(condition) + + // POST method, but with read-only annotation + mockMvc.perform(post("/v2/organizations/1337/requires-owner-read-annotation")).andSatisfies(condition) + } + + private fun performWriteRequests(condition: (ResultActions) -> Unit) { + // GET method, but with write annotation + mockMvc.perform(get("/v2/organizations/1337/default-perms-write-annotation")).andSatisfies(condition) + mockMvc.perform(get("/v2/organizations/1337/requires-owner-write-annotation")).andSatisfies(condition) + + // POST method + mockMvc.perform(post("/v2/organizations/1337/default-perms-write-method")).andSatisfies(condition) + mockMvc.perform(post("/v2/organizations/1337/requires-owner-write-method")).andSatisfies(condition) + } + + private fun ResultActions.andSatisfies(condition: (ResultActions) -> Unit): ResultActions { + condition(this) + return this } @RestController @@ -141,25 +203,58 @@ class OrganizationAuthorizationInterceptorTest { @GetMapping("/v2/organizations/{id}/not-annotated") fun notAnnotated( @PathVariable id: Long, - ) = "henlo from org #$id!" + ) = "hello from org #$id!" @GetMapping("/v2/organizations/{id}/default-perms") @UseDefaultPermissions fun defaultPerms( @PathVariable id: Long, - ) = "henlo from org #$id!" + ) = "hello from org #$id!" + + @PostMapping("/v2/organizations/{id}/default-perms-write-method") + @UseDefaultPermissions + fun defaultPermsWriteMethod( + @PathVariable id: Long, + ) = "hello from org #$id!" + + @GetMapping("/v2/organizations/{id}/default-perms-write-annotation") + @UseDefaultPermissions + @WriteOperation + fun defaultPermsWriteAnnotation( + @PathVariable id: Long, + ) = "hello from org #$id!" + + @GetMapping("/v2/organizations/{id}/requires-owner") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + fun requiresOwner( + @PathVariable id: Long, + ) = "hello from org #$id!" + + @PostMapping("/v2/organizations/{id}/requires-owner-write-method") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + fun requiresOwnerWriteMethod( + @PathVariable id: Long, + ) = "hello from org #$id!" + + @GetMapping("/v2/organizations/{id}/requires-owner-write-annotation") + @WriteOperation + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + fun requiresOwnerWriteAnnotation( + @PathVariable id: Long, + ) = "hello from org #$id!" - @GetMapping("/v2/organizations/{id}/requires-admin") + @PostMapping("/v2/organizations/{id}/requires-owner-read-annotation") + @ReadOnlyOperation @RequiresOrganizationRole(OrganizationRoleType.OWNER) - fun requiresAdmin( + fun requiresOwnerReadAnnotation( @PathVariable id: Long, - ) = "henlo from org #$id!" + ) = "hello from org #$id!" @GetMapping("/v2/organizations/{id}/nonsense-perms") @RequiresOrganizationRole(OrganizationRoleType.OWNER) @UseDefaultPermissions fun nonsensePerms( @PathVariable id: Long, - ) = "henlo from org #$id!" + ) = "hello from org #$id!" } } diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt index b707dd3fa7..533f0af131 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt @@ -32,6 +32,7 @@ import io.tolgee.security.ProjectHolder import io.tolgee.security.ProjectNotSelectedException import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authentication.TolgeeAuthentication import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.SecurityService @@ -42,14 +43,19 @@ import org.junit.jupiter.api.assertThrows import org.mockito.Mockito import org.mockito.kotlin.any import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import io.tolgee.security.authentication.WriteOperation +import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RestController class ProjectAuthorizationInterceptorTest { private val authenticationFacade = Mockito.mock(AuthenticationFacade::class.java) + private val authentication = Mockito.mock(TolgeeAuthentication::class.java) + private val organizationService = Mockito.mock(OrganizationService::class.java) private val securityService = Mockito.mock(SecurityService::class.java) @@ -82,17 +88,21 @@ class ProjectAuthorizationInterceptorTest { @BeforeEach fun setupMocks() { - Mockito.`when`(authenticationFacade.authentication).thenReturn(Mockito.mock(TolgeeAuthentication::class.java)) + Mockito.`when`(authenticationFacade.authentication).thenReturn(authentication) Mockito.`when`(authenticationFacade.authenticatedUser).thenReturn(userAccount) Mockito.`when`(authenticationFacade.isApiAuthentication).thenReturn(false) Mockito.`when`(authenticationFacade.isProjectApiKeyAuth).thenReturn(false) Mockito.`when`(authenticationFacade.isUserSuperAuthenticated).thenReturn(false) Mockito.`when`(authenticationFacade.projectApiKey).thenReturn(apiKey) + Mockito.`when`(authenticationFacade.isReadOnly).thenCallRealMethod() + + Mockito.`when`(authentication.isReadOnly).thenReturn(false) val mockOrganizationDto = Mockito.mock(OrganizationDto::class.java) Mockito.`when`(organizationService.findDto(Mockito.anyLong())).thenReturn(mockOrganizationDto) Mockito.`when`(requestContextService.getTargetProject(any())).thenReturn(projectDto) + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.USER) Mockito.`when`(userAccount.id).thenReturn(1337L) Mockito.`when`(projectDto.id).thenReturn(1337L) Mockito.`when`(project.id).thenReturn(1337L) @@ -106,9 +116,12 @@ class ProjectAuthorizationInterceptorTest { fun resetMocks() { Mockito.reset( authenticationFacade, + authentication, + organizationService, securityService, requestContextService, project, + projectDto, userAccount, apiKey, ) @@ -243,6 +256,63 @@ class ProjectAuthorizationInterceptorTest { assertThrows { mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/implicit-access")) } } + @Test + fun `it allows supporter to bypass checks for read-only project endpoints`() { + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(emptySet()) + + performReadOnlyRequests { all -> all.andIsOk } + } + + @Test + fun `it does not let supporter to bypass checks for write project endpoints`() { + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) + Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(emptySet()) + + performWriteRequests { all -> all.andIsForbidden } + } + + @Test + fun `it allows admin to access any endpoint`() { + Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.ADMIN) + Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(emptySet()) + + performReadOnlyRequests { all -> all.andIsOk } + performWriteRequests { all -> all.andIsOk } + } + + private fun performReadOnlyRequests(condition: (ResultActions) -> Unit) { + // GET method + mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/1337/default-perms")).andSatisfies(condition) + mockMvc.perform(MockMvcRequestBuilders.get("/v2/projects/1337/requires-single-scope")).andSatisfies(condition) + + // POST method, but with read-only annotation + mockMvc.perform( + MockMvcRequestBuilders.post("/v2/projects/1337/requires-single-scope-read-annotation") + ).andSatisfies(condition) + } + + private fun performWriteRequests(condition: (ResultActions) -> Unit) { + // GET method, but with write annotation + mockMvc.perform( + MockMvcRequestBuilders.get("/v2/projects/1337/default-perms-write-annotation") + ).andSatisfies(condition) + mockMvc.perform( + MockMvcRequestBuilders.get("/v2/projects/1337/requires-single-scope-write-annotation") + ).andSatisfies(condition) + + // POST method + mockMvc.perform(MockMvcRequestBuilders.post("/v2/projects/1337/default-perms-write-method")).andSatisfies(condition) + mockMvc.perform( + MockMvcRequestBuilders.post("/v2/projects/1337/requires-single-scope-write-method") + ).andSatisfies(condition) + } + + private fun ResultActions.andSatisfies(condition: (ResultActions) -> Unit): ResultActions { + condition(this) + return this + } + @RestController class TestController { @GetMapping("/v2/projects") @@ -252,35 +322,68 @@ class ProjectAuthorizationInterceptorTest { @GetMapping("/v2/projects/{id}/not-annotated") fun notAnnotated( @PathVariable id: Long, - ) = "henlo from project $id!" + ) = "hello from project $id!" @GetMapping("/v2/projects/{id}/default-perms") @UseDefaultPermissions fun defaultPerms( @PathVariable id: Long, - ) = "henlo from project $id!" + ) = "hello from project $id!" + + @GetMapping("/v2/projects/{id}/default-perms-write-annotation") + @UseDefaultPermissions + @WriteOperation + fun defaultPermsWriteAnnotation( + @PathVariable id: Long, + ) = "hello from project $id!" + + @PostMapping("/v2/projects/{id}/default-perms-write-method") + @UseDefaultPermissions + fun defaultPermsWriteMethod( + @PathVariable id: Long, + ) = "hello from project $id!" @GetMapping("/v2/projects/{id}/requires-single-scope") @RequiresProjectPermissions([ Scope.KEYS_CREATE ]) fun requiresSingleScope( @PathVariable id: Long, - ) = "henlo from project $id!" + ) = "hello from project $id!" + + @GetMapping("/v2/projects/{id}/requires-single-scope-write-annotation") + @WriteOperation + @RequiresProjectPermissions([ Scope.KEYS_CREATE ]) + fun requiresSingleScopeWriteAnnotation( + @PathVariable id: Long, + ) = "hello from project $id!" + + @PostMapping("/v2/projects/{id}/requires-single-scope-write-method") + @RequiresProjectPermissions([ Scope.KEYS_CREATE ]) + fun requiresSingleScopeWriteMethod( + @PathVariable id: Long, + ) = "hello from project $id!" + + @PostMapping("/v2/projects/{id}/requires-single-scope-read-annotation") + @ReadOnlyOperation + @RequiresProjectPermissions([ Scope.KEYS_CREATE ]) + fun requiresSingleScopeReadAnnotation( + @PathVariable id: Long, + ) = "hello from project $id!" @GetMapping("/v2/projects/{id}/requires-multiple-scopes") @RequiresProjectPermissions([ Scope.KEYS_CREATE, Scope.MEMBERS_EDIT ]) fun requiresMultipleScopes( @PathVariable id: Long, - ) = "henlo from project $id!" + ) = "hello from project $id!" @GetMapping("/v2/projects/{id}/nonsense-perms") @UseDefaultPermissions @RequiresProjectPermissions([ Scope.PROJECT_EDIT ]) fun nonsensePerms( @PathVariable id: Long, - ) = "henlo from project $id!" + ) = "hello from project $id!" @GetMapping("/v2/projects/implicit-access") @RequiresProjectPermissions([ Scope.KEYS_CREATE ]) - fun implicitProject() = "henlo from implicit project!" + fun implicitProject() = "hello from implicit project!" } } diff --git a/backend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.kt b/backend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.kt index f1a49f4afd..6534174a10 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/testing/AuthorizedControllerTest.kt @@ -66,7 +66,7 @@ abstract class AuthorizedControllerTest : AbstractControllerTest(), AuthRequestP init(generateJwtToken(_userAccount!!.id)) } - protected fun generateJwtToken(userAccountId: Long) = jwtService.emitToken(userAccountId, true) + protected fun generateJwtToken(userAccountId: Long) = jwtService.emitToken(userAccountId, isSuper = true) fun refreshUser() { _userAccount = userAccountService.findActive(_userAccount!!.id) diff --git a/e2e/cypress/e2e/administration/base.cy.ts b/e2e/cypress/e2e/administration/base.cy.ts index 912f5daaa3..665185ddc4 100644 --- a/e2e/cypress/e2e/administration/base.cy.ts +++ b/e2e/cypress/e2e/administration/base.cy.ts @@ -64,6 +64,7 @@ describe('Administration', () => { visitAdministration(); gcy('settings-menu-item').contains('Users').click(); changeUserRole('John User', 'Admin'); + changeUserRole('John User', 'Supporter'); changeUserRole('John User', 'User'); getUserRoleSelect('Peter Administrator') .find('div') @@ -115,7 +116,7 @@ function getUserRoleSelect(user: string) { return getUserListItem(user).findDcy('administration-user-role-select'); } -function changeUserRole(user: string, role: 'Admin' | 'User') { +function changeUserRole(user: string, role: 'Admin' | 'Supporter' | 'User') { selectInSelect(getUserRoleSelect(user), role); confirmStandard(); assertMessage('Role changed'); diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt index a7f51a74a5..9ce766336f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt @@ -81,7 +81,7 @@ class AiPromptCustomizationController( } @GetMapping("projects/{projectId:[0-9]+}/language-ai-prompt-customizations") - @Operation(summary = "Sets project level prompt customization") + @Operation(summary = "Returns language level prompt customization") @RequiresOrganizationRole(OrganizationRoleType.OWNER) @RequiresProjectPermissions(scopes = [Scope.PROJECT_EDIT, Scope.LANGUAGES_EDIT]) fun getLanguagePromptCustomizations(): CollectionModel { diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/PromptController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/PromptController.kt index e2cc060db0..09584625be 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/PromptController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/PromptController.kt @@ -42,6 +42,7 @@ import org.springframework.web.bind.annotation.* class PromptController( private val promptService: PromptServiceEeImpl, private val promptModelAssembler: PromptModelAssembler, + @Suppress("SpringJavaInjectionPointsAutowiringInspection") private val arrayResourcesAssembler: PagedResourcesAssembler, private val projectHolder: ProjectHolder, private val promptVariablesHelper: PromptVariablesHelper, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt index 28ec6054b2..4a5d134a2b 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.constants.Feature import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.isAdmin import io.tolgee.dtos.sso.SsoTenantDto import io.tolgee.dtos.sso.toDto import io.tolgee.ee.api.v2.hateoas.assemblers.SsoTenantAssembler @@ -12,7 +13,6 @@ import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException import io.tolgee.hateoas.ee.SsoTenantModel import io.tolgee.model.SsoTenant -import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.RequiresSuperAuthentication @@ -46,7 +46,7 @@ class SsoProviderController( ): SsoTenantModel { validateProvider(request) - val isAdmin = authenticationFacade.authenticatedUser.role == UserAccount.Role.ADMIN + val isAdmin = authenticationFacade.authenticatedUser.isAdmin() val organization = organizationService.get(organizationId) return ssoTenantAssembler.toModel( tenantService.createOrUpdate(request.toDto(), organization, allowChangeDomain = isAdmin).toDto(), diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt index 80d169edff..7189cc590b 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt @@ -47,6 +47,7 @@ class SuggestionController( private val translationSuggestionService: TranslationSuggestionServiceEeImpl, private val projectHolder: ProjectHolder, private val translationSuggestionModelAssembler: TranslationSuggestionModelAssembler, + @Suppress("SpringJavaInjectionPointsAutowiringInspection") private val arrayResourcesAssembler: PagedResourcesAssembler, private val authenticationFacade: AuthenticationFacade, private val securityService: SecurityService, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt index 8eb5d11e04..2a0cba03ff 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt @@ -22,6 +22,7 @@ import io.tolgee.model.views.TaskWithScopeView import io.tolgee.openApiDocs.OpenApiOrderExtension import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authorization.RequiresFeatures import io.tolgee.security.authorization.RequiresOneOfFeatures import io.tolgee.security.authorization.RequiresProjectPermissions @@ -313,6 +314,7 @@ class TaskController( @PostMapping("/calculate-scope") @Operation(summary = "Calculate scope") + @ReadOnlyOperation @RequiresProjectPermissions([Scope.TASKS_VIEW]) @AllowApiAccess fun calculateScope( diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt index 490875d7ec..03cf6b4008 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt @@ -53,7 +53,9 @@ class GlossaryController( private val glossaryModelAssembler: GlossaryModelAssembler, private val simpleGlossaryModelAssembler: SimpleGlossaryModelAssembler, private val simpleGlossaryWithStatsModelAssembler: SimpleGlossaryWithStatsModelAssembler, + @Suppress("SpringJavaInjectionPointsAutowiringInspection") private val pagedAssembler: PagedResourcesAssembler, + @Suppress("SpringJavaInjectionPointsAutowiringInspection") private val pagedWithStatsAssembler: PagedResourcesAssembler, private val simpleProjectModelAssembler: SimpleProjectModelAssembler, private val organizationHolder: OrganizationHolder, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt index a5455a564b..09e0358c10 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt @@ -12,6 +12,7 @@ import io.tolgee.openApiDocs.OpenApiUnstableOperationExtension import io.tolgee.security.OrganizationHolder import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.ReadOnlyOperation import io.tolgee.security.authorization.RequiresFeatures import io.tolgee.security.authorization.RequiresProjectPermissions import jakarta.validation.Valid @@ -33,6 +34,7 @@ class GlossaryTermHighlightsController( ) { @PostMapping @Operation(summary = "Returns glossary term highlights for specified text") + @ReadOnlyOperation @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess @RequiresFeatures(Feature.GLOSSARY) diff --git a/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts b/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts index 5269e381de..1b9dbcf74a 100644 --- a/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts +++ b/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts @@ -32,6 +32,9 @@ export const usePermissionsStructure = () => { return { value: 'admin', children: [ + { + value: 'all.view', + }, { label: t('permissions_item_keys'), children: [ diff --git a/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx b/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx index eafb632a80..db41e42549 100644 --- a/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx +++ b/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx @@ -50,6 +50,7 @@ export const useScopeTranslations = () => { 'translation-labels.assign': t( 'permissions_item_translation_labels_assign' ), + 'all.view': t('permissions_item_all_view'), }; return { diff --git a/webapp/src/component/common/useFeatureMissingExplanation.tsx b/webapp/src/component/common/useFeatureMissingExplanation.tsx index 09a6713812..0f9a59fbf4 100644 --- a/webapp/src/component/common/useFeatureMissingExplanation.tsx +++ b/webapp/src/component/common/useFeatureMissingExplanation.tsx @@ -1,12 +1,11 @@ import { useTranslate } from '@tolgee/react'; import { LINKS } from 'tg.constants/links'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { useIsAdmin } from 'tg.globalContext/helpers'; export function useFeatureMissingExplanation() { const subscription = useGlobalContext((c) => c.initialData.eeSubscription); - const isAdmin = useGlobalContext( - (c) => c.initialData.userInfo?.globalServerRole === 'ADMIN' - ); + const isAdmin = useIsAdmin(); const billingEnabled = useGlobalContext( (c) => c.initialData.serverConfiguration.billing.enabled ); diff --git a/webapp/src/component/layout/TopBar/announcements/AdministrationAccessAnnouncement.tsx b/webapp/src/component/layout/TopBar/announcements/AdministrationAccessAnnouncement.tsx index df93f263b7..c562061777 100644 --- a/webapp/src/component/layout/TopBar/announcements/AdministrationAccessAnnouncement.tsx +++ b/webapp/src/component/layout/TopBar/announcements/AdministrationAccessAnnouncement.tsx @@ -1,11 +1,20 @@ import { FC } from 'react'; import { T } from '@tolgee/react'; import { TopBarAnnouncementWithAlertIcon } from './TopBarAnnouncementWithIcon'; +import { useIsSupporter } from 'tg.globalContext/helpers'; export const AdministrationAccessAnnouncement: FC = () => { + const isSupporter = useIsSupporter(); + + const message = isSupporter ? ( + + ) : ( + + ); + return ( - + {message} ); }; diff --git a/webapp/src/component/layout/TopBar/announcements/DebuggingCustomerAccountAnnouncement.tsx b/webapp/src/component/layout/TopBar/announcements/DebuggingCustomerAccountAnnouncement.tsx index 35a2c6295e..44d0706af0 100644 --- a/webapp/src/component/layout/TopBar/announcements/DebuggingCustomerAccountAnnouncement.tsx +++ b/webapp/src/component/layout/TopBar/announcements/DebuggingCustomerAccountAnnouncement.tsx @@ -2,7 +2,10 @@ import { Box, Button, styled } from '@mui/material'; import { T } from '@tolgee/react'; import { useHistory } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; -import { useGlobalActions } from 'tg.globalContext/GlobalContext'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; import { FC } from 'react'; import { TopBarAnnouncementWithAlertIcon } from './TopBarAnnouncementWithIcon'; @@ -15,11 +18,19 @@ export const DebuggingCustomerAccountAnnouncement: FC = () => { const history = useHistory(); const { exitDebugCustomerAccount } = useGlobalActions(); + const isReadOnlyMode = useGlobalContext( + (c) => c.initialData.authInfo?.isReadOnly === true + ); + + const message = isReadOnlyMode ? ( + + ) : ( + + ); + return ( - - - + {message} { const acceptCode = useApiMutation({ url: '/v2/invitations/{code}/accept', - method: 'get', + method: 'put', fetchOptions: { disableErrorNotification: true, }, diff --git a/webapp/src/component/security/UserMenu/UserPresentAvatarMenu.tsx b/webapp/src/component/security/UserMenu/UserPresentAvatarMenu.tsx index 9bab2674c3..f78446708e 100644 --- a/webapp/src/component/security/UserMenu/UserPresentAvatarMenu.tsx +++ b/webapp/src/component/security/UserMenu/UserPresentAvatarMenu.tsx @@ -4,6 +4,7 @@ import { T, useTranslate } from '@tolgee/react'; import { Link, useHistory, useLocation } from 'react-router-dom'; import { + useIsAdminOrSupporter, useIsSsoMigrationRequired, usePreferredOrganization, useUser, @@ -51,6 +52,7 @@ export const UserPresentAvatarMenu: React.FC = () => { const history = useHistory(); const [anchorEl, setAnchorEl] = useState(null); const user = useUser()!; + const isAdminOrSupporter = useIsAdminOrSupporter(); const isSsoMigrationRequired = useIsSsoMigrationRequired(); @@ -159,7 +161,7 @@ export const UserPresentAvatarMenu: React.FC = () => { - {user.globalServerRole == 'ADMIN' && ( + {isAdminOrSupporter && ( = ({ onClose }) => { config.billing.enabled && preferredOrganization && (preferredOrganization?.currentUserRole === 'OWNER' || - user.globalServerRole === 'ADMIN'); + user.globalServerRole === 'ADMIN' || + user.globalServerRole === 'SUPPORTER'); if (!showBilling) { return null; diff --git a/webapp/src/globalContext/helpers.tsx b/webapp/src/globalContext/helpers.tsx index 148a207619..bb988a7099 100644 --- a/webapp/src/globalContext/helpers.tsx +++ b/webapp/src/globalContext/helpers.tsx @@ -21,6 +21,17 @@ export const useEmailAwaitingVerification = () => export const useIsAdmin = () => useGlobalContext((c) => c.initialData.userInfo?.globalServerRole === 'ADMIN'); +export const useIsSupporter = () => + useGlobalContext( + (c) => c.initialData.userInfo?.globalServerRole === 'SUPPORTER' + ); + +export const useIsAdminOrSupporter = () => + useGlobalContext((c) => { + const role = c.initialData.userInfo?.globalServerRole; + return role === 'ADMIN' || role === 'SUPPORTER'; + }); + export const useIsSsoMigrationRequired = () => useGlobalContext( (c) => diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index a1285b6ae7..76eb08e8e2 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -75,7 +75,7 @@ export const useAuthService = ( const acceptInvitationLoadable = useApiMutation({ url: '/v2/invitations/{code}/accept', - method: 'get', + method: 'put', }); const signupLoadable = useApiMutation({ diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 68ade5479c..3869ec1e1c 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -152,6 +152,7 @@ export interface paths { }; "/v2/invitations/{code}/accept": { get: operations["acceptInvitation"]; + put: operations["acceptInvitationPut"]; }; "/v2/invitations/{invitationId}": { delete: operations["deleteInvitation"]; @@ -906,7 +907,7 @@ export interface paths { }; "/v2/projects/{projectId}/users/{userId}/set-by-organization": { /** Removes user's direct project permission, explicitly set for the project. User will have now base permissions from organization or no permission if they're not organization member. */ - put: operations["setOrganizationBase"]; + put: operations["removeDirectProjectPermissions"]; }; "/v2/projects/{projectId}/users/{userId}/set-permissions": { /** Set user's granular (scope-based) direct project permission */ @@ -1195,6 +1196,7 @@ export interface components { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -1254,6 +1256,9 @@ export interface components { userFullName?: string; username?: string; }; + AuthInfoModel: { + isReadOnly: boolean; + }; AuthMethodsDTO: { github: components["schemas"]["OAuthPublicConfigDTO"]; google: components["schemas"]["OAuthPublicConfigDTO"]; @@ -1597,7 +1602,8 @@ export interface components { | "DIRECT" | "ORGANIZATION_OWNER" | "NONE" - | "SERVER_ADMIN"; + | "SERVER_ADMIN" + | "SERVER_SUPPORTER"; permissionModel?: components["schemas"]["PermissionModel"]; /** * @deprecated @@ -1651,6 +1657,7 @@ export interface components { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -2534,7 +2541,10 @@ export interface components { | "suggestion_cant_be_plural" | "suggestion_must_be_plural" | "duplicate_suggestion" - | "unsupported_media_type"; + | "unsupported_media_type" + | "impersonation_of_admin_by_supporter_not_allowed" + | "already_impersonating_user" + | "operation_not_permitted_in_read_only_mode"; params?: unknown[]; }; ExistenceEntityDescription: { @@ -2780,7 +2790,8 @@ export interface components { | "prompts.view" | "prompts.edit" | "translation-labels.manage" - | "translation-labels.assign"; + | "translation-labels.assign" + | "all.view"; }; IdentifyRequest: { anonymousUserId: string; @@ -3050,6 +3061,7 @@ export interface components { }; InitialDataModel: { announcement?: components["schemas"]["AnnouncementDto"]; + authInfo?: components["schemas"]["AuthInfoModel"]; eeSubscription?: components["schemas"]["InitialDataEeSubscriptionModel"]; languageTag?: string; preferredOrganization?: components["schemas"]["PrivateOrganizationModel"]; @@ -4178,6 +4190,7 @@ export interface components { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -4271,6 +4284,7 @@ export interface components { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -4403,7 +4417,7 @@ export interface components { domain?: string; emailAwaitingVerification?: string; /** @enum {string} */ - globalServerRole: "USER" | "ADMIN"; + globalServerRole: "USER" | "ADMIN" | "SUPPORTER"; /** Format: int64 */ id: number; mfaEnabled: boolean; @@ -5804,7 +5818,10 @@ export interface components { | "suggestion_cant_be_plural" | "suggestion_must_be_plural" | "duplicate_suggestion" - | "unsupported_media_type"; + | "unsupported_media_type" + | "impersonation_of_admin_by_supporter_not_allowed" + | "already_impersonating_user" + | "operation_not_permitted_in_read_only_mode"; params?: unknown[]; success: boolean; }; @@ -6268,7 +6285,7 @@ export interface components { disabled: boolean; emailAwaitingVerification?: string; /** @enum {string} */ - globalServerRole: "USER" | "ADMIN"; + globalServerRole: "USER" | "ADMIN" | "SUPPORTER"; /** Format: int64 */ id: number; mfaEnabled: boolean; @@ -7129,7 +7146,7 @@ export interface operations { parameters: { path: { userId: number; - role: "USER" | "ADMIN"; + role: "USER" | "ADMIN" | "SUPPORTER"; }; }; responses: { @@ -8073,6 +8090,41 @@ export interface operations { }; }; }; + acceptInvitationPut: { + parameters: { + path: { + code: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; deleteInvitation: { parameters: { path: { @@ -19159,7 +19211,7 @@ export interface operations { }; }; /** Removes user's direct project permission, explicitly set for the project. User will have now base permissions from organization or no permission if they're not organization member. */ - setOrganizationBase: { + removeDirectProjectPermissions: { parameters: { path: { userId: number; @@ -19875,6 +19927,7 @@ export interface operations { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; }; }; diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index 777b883fb6..a0647b4aaa 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -207,6 +207,8 @@ export function useErrorTranslation() { return t('suggestion_must_be_plural'); case 'duplicate_suggestion': return t('duplicate_suggestion'); + case 'operation_not_permitted_in_read_only_mode': + return t('operation_not_permitted_in_read_only_mode'); default: return code; } diff --git a/webapp/src/views/administration/components/RoleSelector.tsx b/webapp/src/views/administration/components/RoleSelector.tsx index 6fe326b871..d497ab3ec7 100644 --- a/webapp/src/views/administration/components/RoleSelector.tsx +++ b/webapp/src/views/administration/components/RoleSelector.tsx @@ -50,13 +50,16 @@ export const RoleSelector: FC<{ disabled={currentUser?.id === user.id} size="small" value={user.globalServerRole} - onChange={(e) => setRole(user.id, e.target.value as any)} + onChange={(e) => setRole(user.id, e.target.value as Role)} inputProps={{ style: { padding: 0 } }} > - + - + + + + diff --git a/webapp/src/views/organizations/OrganizationsRouter.tsx b/webapp/src/views/organizations/OrganizationsRouter.tsx index c68e2b3cc0..270d071dea 100644 --- a/webapp/src/views/organizations/OrganizationsRouter.tsx +++ b/webapp/src/views/organizations/OrganizationsRouter.tsx @@ -5,7 +5,7 @@ import { BoxLoading } from 'tg.component/common/BoxLoading'; import { PrivateRoute } from 'tg.component/common/PrivateRoute'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS } from 'tg.constants/links'; -import { useIsAdmin } from 'tg.globalContext/helpers'; +import { useIsAdminOrSupporter } from 'tg.globalContext/helpers'; import { OrganizationCreateView } from './OrganizationCreateView'; import { OrganizationMemberPrivilegesView } from './OrganizationMemberPrivilegesView'; @@ -17,9 +17,11 @@ import { routes } from 'tg.ee'; const SpecificOrganizationRouter = () => { const organization = useOrganization(); - const isAdmin = useIsAdmin(); + const isAdminOrSupporter = useIsAdminOrSupporter(); const isAdminAccess = - organization && organization?.currentUserRole !== 'OWNER' && isAdmin; + organization && + organization?.currentUserRole !== 'OWNER' && + isAdminOrSupporter; return ( diff --git a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx index 82f0e4ec9c..f3cf893785 100644 --- a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx +++ b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx @@ -12,7 +12,7 @@ import { BaseSettingsView } from 'tg.component/layout/BaseSettingsView/BaseSetti import { SettingsMenuItem } from 'tg.component/layout/BaseSettingsView/SettingsMenu'; import { useConfig, - useIsAdmin, + useIsAdminOrSupporter, usePreferredOrganization, } from 'tg.globalContext/helpers'; import { CriticalUsageCircle } from 'tg.ee'; @@ -36,7 +36,7 @@ export const BaseOrganizationSettingsView: React.FC = ({ const { t } = useTranslate(); const history = useHistory(); const { preferredOrganization } = usePreferredOrganization(); - const isAdmin = useIsAdmin(); + const isAdminOrSupporter = useIsAdminOrSupporter(); const handleOrganizationSelect = (organization: OrganizationModel) => { const redirectLink = @@ -50,7 +50,7 @@ export const BaseOrganizationSettingsView: React.FC = ({ }; const canManageOrganization = - preferredOrganization?.currentUserRole === 'OWNER' || isAdmin; + preferredOrganization?.currentUserRole === 'OWNER' || isAdminOrSupporter; const menuItems: SettingsMenuItem[] = [ { diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index 8cdb671583..092e8a6b07 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -11,7 +11,10 @@ import { useApiQuery } from 'tg.service/http/useQueryApi'; import DashboardProjectListItem from 'tg.views/projects/DashboardProjectListItem'; import { Button, styled } from '@mui/material'; import { Link } from 'react-router-dom'; -import { useIsAdmin, usePreferredOrganization } from 'tg.globalContext/helpers'; +import { + useIsAdminOrSupporter, + usePreferredOrganization, +} from 'tg.globalContext/helpers'; import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CriticalUsageCircle } from 'tg.ee'; @@ -53,9 +56,10 @@ export const ProjectListView = () => { preferredOrganization?.currentUserRole || '' ); - const isAdmin = useIsAdmin(); + const isAdminOrSupporter = useIsAdminOrSupporter(); - const isAdminAccess = !preferredOrganization?.currentUserRole && isAdmin; + const isAdminAccess = + !preferredOrganization?.currentUserRole && isAdminOrSupporter; const addAllowed = isOrganizationOwnerOrMaintainer || isAdminAccess; diff --git a/webapp/src/views/projects/ProjectPage.tsx b/webapp/src/views/projects/ProjectPage.tsx index 4cb21cd235..f5dbc6e38a 100644 --- a/webapp/src/views/projects/ProjectPage.tsx +++ b/webapp/src/views/projects/ProjectPage.tsx @@ -22,7 +22,9 @@ export const ProjectPage: React.FC = ({ }) => { const project = useProject(); - const isAdminAccess = project.computedPermission.origin === 'SERVER_ADMIN'; + const isAdminAccess = + project.computedPermission.origin === 'SERVER_ADMIN' || + project.computedPermission.origin === 'SERVER_SUPPORTER'; return ( = (props) => { }, }); - const availableScopesLoadable = useApiQuery({ - url: '/v2/api-keys/availableScopes', - method: 'get', - }); - const editMutation = useApiMutation({ url: '/v2/api-keys/{apiKeyId}', method: 'put', @@ -117,40 +112,38 @@ export const EditApiKeyDialog: FunctionComponent = (props) => { <> - {(availableScopesLoadable.isLoading || - apiKeyLoadable.isLoading || - projectLoadable.isLoading) && } - {projectLoadable.data && - availableScopesLoadable.data && - apiKeyLoadable.data && ( - onDialogClose()} - initialValues={getInitialValues()} - validationSchema={Validation.EDIT_API_KEY} - > - <> - } + {(apiKeyLoadable.isLoading || projectLoadable.isLoading) && ( + + )} + {projectLoadable.data && apiKeyLoadable.data && ( + onDialogClose()} + initialValues={getInitialValues()} + validationSchema={Validation.EDIT_API_KEY} + > + <> + } + /> + + + - - - - - - - )} + + + + )}