From add54524ca3168567381f4bf16712e260180165f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 18 Sep 2025 17:40:25 +0200 Subject: [PATCH 01/29] feat: supporter user role --- .../api/v2/controllers/ApiKeyController.kt | 7 +- .../v2/controllers/BusinessEventController.kt | 2 +- .../v2/controllers/V2InvitationController.kt | 5 +- .../organization/OrganizationController.kt | 6 +- .../controllers/project/ProjectsController.kt | 2 +- .../project/ProjectsTransferringController.kt | 2 +- .../hateoas/project/ProjectModelAssembler.kt | 5 +- .../project/ProjectWithStatsModelAssembler.kt | 5 +- .../constants/ComputedPermissionOrigin.kt | 1 + .../io/tolgee/dtos/ComputedPermissionDto.kt | 27 +++++++- .../tolgee/dtos/cacheable/UserAccountDto.kt | 16 +++++ .../kotlin/io/tolgee/model/UserAccount.kt | 25 +++++++ .../model/enums/OrganizationRoleType.kt | 10 +-- .../model/enums/ProjectPermissionType.kt | 4 +- .../kotlin/io/tolgee/model/enums/Scope.kt | 54 ++++++++++++--- .../authentication/TolgeeAuthentication.kt | 12 +++- .../organization/OrganizationRoleService.kt | 66 ++++++++++-------- .../organization/OrganizationService.kt | 9 +-- .../service/security/PermissionService.kt | 4 +- .../service/security/SecurityService.kt | 23 ++++--- .../OrganizationAuthorizationInterceptor.kt | 10 +-- .../ProjectAuthorizationInterceptor.kt | 11 +-- .../v2/controllers/SsoProviderController.kt | 4 +- .../useScopeTranslations.tsx | 1 + .../userSettings/apiKeys/EditApiKeyDialog.tsx | 69 +++++++++---------- 25 files changed, 248 insertions(+), 132 deletions(-) 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..e2537fe4ff 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,9 +77,10 @@ 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) } + // TODO: read only token for supporter return apiKeyService.create( userAccount = authenticationFacade.authenticatedUserEntity, scopes = dto.scopes, @@ -312,7 +313,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/BusinessEventController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt index 792e9d5eb1..8973bf672e 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt @@ -34,7 +34,7 @@ class BusinessEventController( @RequestBody eventData: BusinessEventReportRequest, ) { try { - eventData.projectId?.let { securityService.checkAnyProjectPermission(it) } + eventData.projectId?.let { securityService.checkAnyProjectPermission(it, isReadonlyAccess = true) } eventData.organizationId?.let { organizationRoleService.checkUserCanView(it) } businessEventPublisher.publish(eventData) } catch (e: Throwable) { 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..892087b217 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 @@ -78,7 +78,10 @@ class V2InvitationController( } invitation.organizationRole?.let { - organizationRoleService.checkUserIsOwner(invitation.organizationRole!!.organization!!.id) + organizationRoleService.checkUserIsOwner( + invitation.organizationRole!!.organization!!.id, + isReadOnlyAccess = false, + ) } invitationService.delete(invitation) 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 4cb4866f5e..94031e76ab 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 @@ -104,12 +104,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..0eee5a867e 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.checkUserIsOwnerOrMaintainer(dto.organizationId, isReadOnlyAccess = false) val project = projectCreationService.createProject(dto) if (organizationRoleService.getType(dto.organizationId) == OrganizationRoleType.MAINTAINER) { // Maintainers get full access to projects they create 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..7b7f847acc 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.checkUserIsOwner(organizationId, isReadOnlyAccess = false) projectService.transferToOrganization(projectId, organizationId) } } 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/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/dtos/ComputedPermissionDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt index 6dfc58c25e..edc1a3c2b0 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,21 @@ 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 { if (userRole == UserAccount.Role.ADMIN && !this.isAllPermitted) { return SERVER_ADMIN } + if (userRole == UserAccount.Role.SUPPORTER) { + if (this === NONE) { + return SERVER_SUPPORTER + } + return ComputedPermissionDto( + getExtendedPermission(this, arrayOf(Scope.ALL_VIEW)), + origin = ComputedPermissionOrigin.SERVER_SUPPORTER, + ) + } return this } @@ -109,6 +118,13 @@ class ComputedPermissionDto( } } + private fun getExtendedPermission(base: IPermission, extendedScopes: Array): IPermission { + return object : IPermission by base { + override val scopes: Array + get() = base.scopes + extendedScopes + } + } + val NONE get() = ComputedPermissionDto(getEmptyPermission(scopes = arrayOf(), ProjectPermissionType.NONE)) val ORGANIZATION_OWNER @@ -129,5 +145,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..38cad7708c 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,19 @@ 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 +} + +fun UserAccountDto.hasAdminAccess(isReadonlyAccess: Boolean): Boolean { + return role?.hasAdminAccess(isReadonlyAccess) ?: false +} 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..3c7de0c241 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,15 @@ data class UserAccount( enum class Role { USER, ADMIN, + SUPPORTER; + + fun hasAdminAccess(isReadonlyAccess: Boolean): Boolean { + return when (this) { + ADMIN -> true + SUPPORTER -> isReadonlyAccess + else -> false + } + } } enum class AccountType { @@ -175,3 +184,19 @@ 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 +} + +fun UserAccount.hasAdminAccess(isReadonlyAccess: Boolean): Boolean { + return role?.hasAdminAccess(isReadonlyAccess) ?: false +} 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..e7dd0f4f7f 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 { + + private 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/security/authentication/TolgeeAuthentication.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/TolgeeAuthentication.kt index d237ed8683..23527b6931 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 @@ -41,10 +41,19 @@ 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() @@ -73,6 +82,7 @@ class TolgeeAuthentication( companion object { const val ROLE_USER = "ROLE_USER" + const val ROLE_SUPPORTER = "ROLE_SUPPORTER" const val ROLE_ADMIN = "ROLE_ADMIN" } } 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 e13f4a4e00..52573ad354 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.hasAdminAccess +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 @@ -28,6 +30,7 @@ import org.springframework.transaction.annotation.Transactional import java.util.EnumSet @Service +@Suppress("SelfReferenceConstructorParameter") class OrganizationRoleService( private val organizationRoleRepository: OrganizationRoleRepository, private val authenticationFacade: AuthenticationFacade, @@ -37,7 +40,7 @@ class OrganizationRoleService( private val organizationRepository: OrganizationRepository, @Lazy private val userPreferencesService: UserPreferencesService, - @Suppress("SelfReferenceConstructorParameter") @Lazy + @Lazy private val self: OrganizationRoleService, private val cacheManager: CacheManager, ) { @@ -48,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, ) ) { @@ -83,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. @@ -115,51 +116,56 @@ class OrganizationRoleService( fun checkUserIsOwner( userId: Long, organizationId: Long, + isReadOnlyAccess: Boolean, ) { - val isServerAdmin = userAccountService.getDto(userId).role == UserAccount.Role.ADMIN - if (this.isUserOwner( - userId, - organizationId, - ) || isServerAdmin - ) { + if (this.isUserOwner(userId, organizationId)) { return - } else { - throw PermissionException(Message.USER_IS_NOT_OWNER_OF_ORGANIZATION) } + + if (userAccountService.getDto(userId).hasAdminAccess(isReadOnlyAccess)) { + return + } + + throw PermissionException(Message.USER_IS_NOT_OWNER_OF_ORGANIZATION) } fun checkUserIsOwnerOrMaintainer( userId: Long, organizationId: Long, + isReadOnlyAccess: Boolean, ) { - val isServerAdmin = userAccountService.getDto(userId).role == UserAccount.Role.ADMIN - if (this.isUserOwnerOrMaintainer( - userId, - organizationId, - ) || isServerAdmin - ) { + if (this.isUserOwnerOrMaintainer(userId, organizationId)) { return - } else { - throw PermissionException(Message.USER_IS_NOT_OWNER_OR_MAINTAINER_OF_ORGANIZATION) } + + if (userAccountService.getDto(userId).hasAdminAccess(isReadOnlyAccess)) { + return + } + + throw PermissionException(Message.USER_IS_NOT_OWNER_OR_MAINTAINER_OF_ORGANIZATION) } - fun checkUserIsOwner(organizationId: Long) { - this.checkUserIsOwner(authenticationFacade.authenticatedUser.id, organizationId) + fun checkUserIsOwner(organizationId: Long, isReadOnlyAccess: Boolean) { + this.checkUserIsOwner(authenticationFacade.authenticatedUser.id, organizationId, isReadOnlyAccess) } - fun checkUserIsOwnerOrMaintainer(organizationId: Long) { - this.checkUserIsOwnerOrMaintainer(authenticationFacade.authenticatedUser.id, organizationId) + fun checkUserIsOwnerOrMaintainer(organizationId: Long, isReadOnlyAccess: Boolean) { + this.checkUserIsOwnerOrMaintainer(authenticationFacade.authenticatedUser.id, organizationId, isReadOnlyAccess) } fun checkUserIsMember( userId: Long, organizationId: Long, + isReadOnlyAccess: Boolean, ) { - val isServerAdmin = userAccountService.getDto(userId).role == UserAccount.Role.ADMIN - if (hasAnyOrganizationRole(userId, organizationId) || isServerAdmin) { + if (hasAnyOrganizationRole(userId, organizationId)) { return } + + if (userAccountService.getDto(userId).hasAdminAccess(isReadOnlyAccess)) { + return + } + throw PermissionException(Message.USER_IS_NOT_MEMBER_OF_ORGANIZATION) } 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/PermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt index 23d4840e1d..b70cb207b7 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 @@ -478,7 +478,9 @@ class PermissionService( userId: Long, ) { val project = projectService.get(projectId) - organizationRoleService.checkUserIsMember(userId, project.organizationOwner.id) + // organizationRoleService.checkUserIsMember(userId, project.organizationOwner.id) + // FIXME: this check shouldn't be here, no? In the doc string it says the user will have no permissions if they + // are not part of the organization, so we should allow this even when the user is not a member. 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..a33a2105ff 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,7 @@ 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.hasAdminAccess import io.tolgee.exceptions.LanguageNotPermittedException import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException @@ -48,10 +49,10 @@ class SecurityService( @Autowired private lateinit var labelService: LabelService - fun checkAnyProjectPermission(projectId: Long) { + fun checkAnyProjectPermission(projectId: Long, isReadonlyAccess: Boolean) { if ( getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() && - !isCurrentUserServerAdmin() + !hasCurrentUserServerAdminAccess(isReadonlyAccess) ) { throw PermissionException(Message.USER_HAS_NO_PROJECT_ACCESS) } @@ -154,7 +155,7 @@ class SecurityService( requiredScope: Scope, userAccountDto: UserAccountDto, ) { - if (isUserAdmin(userAccountDto)) { + if (userAccountDto.hasAdminAccess(isReadonlyAccess = requiredScope.isReadOnly())) { return } @@ -217,6 +218,7 @@ class SecurityService( checkProjectPermission(projectId, Scope.TRANSLATIONS_SUGGEST) checkLanguagePermission( projectId, + isReadonlyAccess = false, ) { data -> data.checkSuggestPermitted(*languageIds.toLongArray()) } } @@ -227,6 +229,7 @@ class SecurityService( checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) checkLanguagePermission( projectId, + isReadonlyAccess = true, ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } } @@ -239,6 +242,7 @@ class SecurityService( checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) checkLanguagePermission( projectId, + isReadonlyAccess = true, ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } if (keyId != null && languageIds.isNotEmpty()) { @@ -263,6 +267,7 @@ class SecurityService( checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) checkLanguagePermission( projectId, + isReadonlyAccess = false, ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } }, { @@ -294,6 +299,7 @@ class SecurityService( checkProjectPermission(projectId, Scope.TRANSLATIONS_STATE_EDIT) checkLanguagePermission( projectId, + isReadonlyAccess = false, ) { data -> data.checkStateChangePermitted(*languageIds.toLongArray()) } } catch (e: PermissionException) { if (!translationsInTask(projectId, TaskType.REVIEW, languageIds, keyId)) { @@ -332,9 +338,10 @@ class SecurityService( private fun checkLanguagePermission( projectId: Long, + isReadonlyAccess: Boolean, permissionCheckFn: (data: ComputedPermissionDto) -> Unit, ) { - if (isCurrentUserServerAdmin()) { + if (hasCurrentUserServerAdminAccess(isReadonlyAccess)) { return } val usersPermission = @@ -544,12 +551,8 @@ class SecurityService( } } - private fun isCurrentUserServerAdmin(): Boolean { - return isUserAdmin(activeUser) - } - - private fun isUserAdmin(user: UserAccountDto): Boolean { - return user.role == UserAccount.Role.ADMIN + private fun hasCurrentUserServerAdminAccess(isReadonlyAccess: Boolean): Boolean { + return activeUser.hasAdminAccess(isReadonlyAccess) } private val activeUser: UserAccountDto 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..d6e1c751a8 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,9 +16,9 @@ package io.tolgee.security.authorization +import io.tolgee.dtos.cacheable.hasAdminAccess 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 @@ -52,7 +52,8 @@ class OrganizationAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { - val userId = authenticationFacade.authenticatedUser.id + val user = authenticationFacade.authenticatedUser + val userId = user.id val organization = requestContextService.getTargetOrganization(request) // Two possible scenarios: we're on `GET/POST /v2/organization`, or the organization was not found. @@ -61,7 +62,6 @@ class OrganizationAuthorizationInterceptor( ?: 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,7 +71,7 @@ class OrganizationAuthorizationInterceptor( ) if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { - if (!isAdmin) { + if (!user.hasAdminAccess(isReadonlyAccess = true)) { logger.debug( "Rejecting access to org#{} for user#{} - No view permissions", organization.id, @@ -86,7 +86,7 @@ class OrganizationAuthorizationInterceptor( } if (requiredRole != null && !organizationRoleService.isUserOfRole(userId, organization.id, requiredRole)) { - if (!isAdmin) { + if (!user.hasAdminAccess(isReadonlyAccess = requiredRole.isReadOnly)) { logger.debug( "Rejecting access to org#{} for user#{} - Insufficient role", organization.id, 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..0b2839e407 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,10 +18,10 @@ package io.tolgee.security.authorization import io.tolgee.activity.ActivityHolder import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.hasAdminAccess 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 @@ -56,6 +56,7 @@ class ProjectAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { + val user = authenticationFacade.authenticatedUser val userId = authenticationFacade.authenticatedUser.id val project = requestContextService.getTargetProject(request) @@ -65,7 +66,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,7 +74,7 @@ class ProjectAuthorizationInterceptor( val scopes = securityService.getCurrentPermittedScopes(project.id) if (scopes.isEmpty()) { - if (!isAdmin) { + if (!user.hasAdminAccess(isReadonlyAccess = true)) { logger.debug( "Rejecting access to proj#{} for user#{} - No view permissions", project.id, @@ -91,7 +91,10 @@ class ProjectAuthorizationInterceptor( val missingScopes = getMissingScopes(requiredScopes, scopes.toSet()) if (missingScopes.isNotEmpty()) { - if (!isAdmin || authenticationFacade.isProjectApiKeyAuth) { + val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = Scope.areAllReadOnly(requiredScopes)) + val canUseAdminRights = !authenticationFacade.isProjectApiKeyAuth + val canBypass = hasAdminAccess && canUseAdminRights + if (!canBypass) { logger.debug( "Rejecting access to proj#{} for user#{} - Insufficient permissions", project.id, 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/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/views/userSettings/apiKeys/EditApiKeyDialog.tsx b/webapp/src/views/userSettings/apiKeys/EditApiKeyDialog.tsx index db4c4012b4..48c029459f 100644 --- a/webapp/src/views/userSettings/apiKeys/EditApiKeyDialog.tsx +++ b/webapp/src/views/userSettings/apiKeys/EditApiKeyDialog.tsx @@ -52,11 +52,6 @@ export const EditApiKeyDialog: FunctionComponent = (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} + > + <> + } + /> + + + - - - - - - - )} + + + + )} From 15c5f85247baac34048da183e1b4c0851df225ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 18 Sep 2025 19:45:40 +0200 Subject: [PATCH 02/29] feat: implemented read only tokens and impersonation actor support --- .../controllers/AdministrationController.kt | 9 +++- .../api/v2/controllers/UserMfaController.kt | 4 +- .../api/v2/controllers/V2UserController.kt | 4 +- .../AuthProviderChangeController.kt | 4 +- .../io/tolgee/controllers/PublicController.kt | 6 ++- .../tolgee/configuration/WebSecurityConfig.kt | 3 +- .../app/src/test/kotlin/io/tolgee/AuthTest.kt | 4 +- .../ProjectApiKeyAuthenticationTest.kt | 2 +- .../io/tolgee/service/LanguageServiceTest.kt | 4 +- .../service/dataImport/ImportServiceTest.kt | 4 +- .../dataImport/StoredDataImporterTest.kt | 4 +- .../authentication/AuthenticationFacade.kt | 7 +++ .../security/authentication/JwtService.kt | 45 +++++++++++++++++-- .../authentication/TolgeeAuthentication.kt | 2 + .../io/tolgee/service/StartupImportService.kt | 2 + .../io/tolgee/service/security/MfaService.kt | 2 +- .../tolgee/service/security/SignUpService.kt | 2 +- .../security/authentication/JwtServiceTest.kt | 5 ++- ...iveOperationProtectionE2eDataController.kt | 4 +- .../authentication/AuthenticationFilter.kt | 6 +++ .../AuthenticationFilterTest.kt | 2 + .../testing/AuthorizedControllerTest.kt | 2 +- 22 files changed, 100 insertions(+), 27 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt index 150b328368..d0669fab6f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt @@ -3,6 +3,7 @@ package io.tolgee.api.v2.controllers import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag 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 @@ -147,7 +148,13 @@ class AdministrationController( fun generateUserToken( @PathVariable userId: Long, ): String { + val actingUser = authenticationFacade.authenticatedUser val user = userAccountService.get(userId) - return jwtService.emitToken(user.id, true) + return jwtService.emitToken( + user.id, + actingAsUserAccountId = actingUser.id, + isReadOnly = !actingUser.isAdmin(), + isSuper = true + ) } } 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/V2UserController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt index 1c59bf8954..060621a1fc 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(isSuper = true), ) } @@ -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/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..e178b4e738 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) } @@ -102,7 +104,7 @@ class PublicController( @PathVariable("code") @NotBlank code: String, ): JwtAuthenticationResponse { emailVerificationService.verify(userId, code) - return JwtAuthenticationResponse(jwtService.emitToken(userId)) + return JwtAuthenticationResponse(jwtService.emitToken(userId, isSuper = false)) } @PostMapping(value = ["/validate_email"], consumes = [MediaType.APPLICATION_JSON_VALUE]) 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..9df1226993 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -94,7 +94,8 @@ class WebSecurityConfig( }, ) it.requestMatchers(*PUBLIC_ENDPOINTS).permitAll() - it.requestMatchers("/v2/administration/**", "/v2/ee-license/**").hasRole("ADMIN") + // TODO: not all endpoints should be available to supporters - per endpoint limit to ADMIN only + it.requestMatchers("/v2/administration/**", "/v2/ee-license/**").hasRole("SUPPORTER") it.requestMatchers("/api/**", "/v2/**").authenticated() it.anyRequest().permitAll() }.headers { headers -> 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/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/service/LanguageServiceTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt index 605caf474c..c96b4aa9e9 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt @@ -155,7 +155,9 @@ class LanguageServiceTest : AbstractSpringTest() { TolgeeAuthentication( null, UserAccountDto.fromEntity(user), - null, + actingAsUserAccount = null, + readOnly = false, + details = null, ) } 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..02e1680b2f 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 @@ -124,7 +124,9 @@ class ImportServiceTest : AbstractSpringTest() { TolgeeAuthentication( null, UserAccountDto.fromEntity(testData.userAccount), - TolgeeAuthenticationDetails(false), + actingAsUserAccount = null, + readOnly = false, + details = TolgeeAuthenticationDetails(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..5d36241ae9 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 @@ -46,7 +46,9 @@ class StoredDataImporterTest : AbstractSpringTest() { TolgeeAuthentication( null, UserAccountDto.fromEntity(importTestData.userAccount), - TolgeeAuthenticationDetails(false), + actingAsUserAccount = null, + readOnly = false, + details = TolgeeAuthenticationDetails(false), ) } 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..660517feea 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,7 +79,14 @@ class AuthenticationFacade( return authentication.userAccountView } + // -- ACTING USER + val actingUser: UserAccountDto? + get() = authentication.actingAsUserAccount + // -- AUTHENTICATION METHOD AND DETAILS + val isReadOnly: Boolean + get() = authentication.readOnly + val isUserSuperAuthenticated: Boolean get() = if (isAuthenticated) authentication.details?.isSuperToken == true else false 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..8bbe61b691 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 @@ -43,6 +43,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,11 +55,15 @@ 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 @@ -72,6 +77,14 @@ class JwtService( .setSubject(userAccountId.toString()) .setExpiration(expiration) + if (actingAsUserAccountId != null) { + builder.claim(JWT_TOKEN_ACTING_USER_ID_CLAIM, actingAsUserAccountId) + } + + 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 +93,17 @@ class JwtService( return builder.compact() } + fun emitTokenRefreshForCurrentUser( + isSuper: Boolean? = false, + ): String { + return emitToken( + userAccountId = authenticationFacade.authenticatedUser.id, + actingAsUserAccountId = authenticationFacade.actingUser?.id, + isReadOnly = authenticationFacade.isReadOnly, + isSuper = isSuper ?: authenticationFacade.isUserSuperAuthenticated, + ) + } + /** * Emits a ticket for a given user. * @@ -133,13 +157,19 @@ class JwtService( throw AuthExpiredException(Message.EXPIRED_JWT_TOKEN) } + val actor = validateActor(jws.body) + 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 + return TolgeeAuthentication( - jws, - account, - TolgeeAuthenticationDetails(hasSuperPowers), + credentials = jws, + userAccount = account, + actingAsUserAccount = actor, + readOnly = roClaim, + details = TolgeeAuthenticationDetails(hasSuperPowers), ) } @@ -190,6 +220,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 +249,8 @@ 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 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 23527b6931..a015e5100c 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 @@ -28,6 +28,8 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority class TolgeeAuthentication( private val credentials: Any?, private val userAccount: UserAccountDto, + val actingAsUserAccount: UserAccountDto?, + val readOnly: Boolean, private val details: TolgeeAuthenticationDetails?, ) : Authentication { var userAccountEntity: UserAccount? = null 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..f10c33e4e6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt @@ -84,6 +84,8 @@ class StartupImportService( TolgeeAuthentication( credentials = null, userAccount = UserAccountDto.fromEntity(userAccount), + actingAsUserAccount = null, + readOnly = false, details = TolgeeAuthenticationDetails(false), ) } 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/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..60caa2fb57 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 @@ -58,6 +58,7 @@ class JwtServiceTest { authenticationProperties, currentDateProvider, userAccountService, + authenticationFacade, ) @BeforeEach @@ -104,7 +105,7 @@ 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) @@ -116,7 +117,7 @@ class JwtServiceTest { @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)) 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 a63ca1c30e..ff51853edd 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 @@ -33,14 +33,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/AuthenticationFilter.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt index 4b2a8ae8fa..4c7d0514c2 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 @@ -117,6 +117,8 @@ class AuthenticationFilter( TolgeeAuthentication( null, initialUser, + null, + false, TolgeeAuthenticationDetails(true), ) } @@ -165,6 +167,8 @@ class AuthenticationFilter( TolgeeAuthentication( pak, userAccount, + null, + false, TolgeeAuthenticationDetails(false), ) } @@ -190,6 +194,8 @@ class AuthenticationFilter( TolgeeAuthentication( pat, userAccount, + null, + false, TolgeeAuthenticationDetails(false), ) } 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..8167471282 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 @@ -135,6 +135,8 @@ class AuthenticationFilterTest { "uwu", userAccountDto, null, + false, + null, ), ) 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) From 65ed5a60bcf379685d3e9d56d616e192aefb54c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 23 Sep 2025 14:40:38 +0200 Subject: [PATCH 03/29] feat: handle read only distinction in interceptors --- .../controllers/AdministrationController.kt | 12 +++- .../api/v2/controllers/ApiKeyController.kt | 1 - .../controllers/project/ProjectsController.kt | 4 +- .../tolgee/configuration/WebSecurityConfig.kt | 25 +++++--- .../v2/controllers/V2UserControllerTest.kt | 4 +- .../io/tolgee/service/LanguageServiceTest.kt | 7 ++- .../service/dataImport/ImportServiceTest.kt | 7 ++- .../dataImport/StoredDataImporterTest.kt | 7 ++- .../kotlin/io/tolgee/constants/Message.kt | 2 +- .../authentication/AuthenticationFacade.kt | 4 +- .../security/authentication/JwtService.kt | 19 +++++- .../authentication/TolgeeAuthentication.kt | 26 +++++++- .../TolgeeAuthenticationDetails.kt | 9 +-- .../io/tolgee/service/StartupImportService.kt | 3 +- .../service/security/PermissionService.kt | 6 +- .../security/authentication/JwtServiceTest.kt | 8 ++- .../authentication/AdminAccessInterceptor.kt | 59 ++++++++++++++++++ .../authentication/AllowInReadOnlyMode.kt | 27 ++++++++ .../authentication/AuthenticationFilter.kt | 33 +++++----- .../authentication/ReadOnlyModeInterceptor.kt | 61 +++++++++++++++++++ .../authentication/RequiresReadWriteMode.kt | 27 ++++++++ .../AbstractAuthorizationInterceptor.kt | 25 +++++++- .../FeatureAuthorizationInterceptor.kt | 5 +- .../OrganizationAuthorizationInterceptor.kt | 18 +++++- .../ProjectAuthorizationInterceptor.kt | 19 +++++- .../AuthenticationFilterTest.kt | 11 ++-- 26 files changed, 354 insertions(+), 75 deletions(-) create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt index d0669fab6f..3446e7df05 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt @@ -11,6 +11,7 @@ import io.tolgee.hateoas.organization.OrganizationModelAssembler import io.tolgee.hateoas.userAccount.UserAccountModel import io.tolgee.hateoas.userAccount.UserAccountModelAssembler import io.tolgee.model.UserAccount +import io.tolgee.model.isAdmin import io.tolgee.openApiDocs.OpenApiSelfHostedExtension import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.JwtService @@ -150,11 +151,16 @@ class AdministrationController( ): String { val actingUser = authenticationFacade.authenticatedUser val user = userAccountService.get(userId) + val isAdmin = actingUser.isAdmin() + 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.emitToken( - user.id, + userAccountId = user.id, actingAsUserAccountId = actingUser.id, - isReadOnly = !actingUser.isAdmin(), - isSuper = true + isReadOnly = !isAdmin, + isSuper = isAdmin ) } } 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 e2537fe4ff..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 @@ -80,7 +80,6 @@ class ApiKeyController( if (!authenticationFacade.authenticatedUser.isAdmin()) { securityService.checkApiKeyScopes(dto.scopes, project) } - // TODO: read only token for supporter return apiKeyService.create( userAccount = authenticationFacade.authenticatedUserEntity, scopes = dto.scopes, 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 0eee5a867e..7c69609aeb 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 @@ -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/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt index 9df1226993..3bb0326e7b 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,8 +100,7 @@ class WebSecurityConfig( }, ) it.requestMatchers(*PUBLIC_ENDPOINTS).permitAll() - // TODO: not all endpoints should be available to supporters - per endpoint limit to ADMIN only - it.requestMatchers("/v2/administration/**", "/v2/ee-license/**").hasRole("SUPPORTER") + it.requestMatchers(*ADMIN_ENDPOINTS).hasRole("SUPPORTER") it.requestMatchers("/api/**", "/v2/**").authenticated() it.anyRequest().permitAll() }.headers { headers -> @@ -116,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/**") registry.addInterceptor(projectAuthorizationInterceptor) .addPathPatterns("/v2/projects/**", "/api/project/**", "/api/repository/**") + registry.addInterceptor(adminAccessInterceptor) + .addPathPatterns(*ADMIN_ENDPOINTS) + registry.addInterceptor(readOnlyModeInterceptor) + .excludePathPatterns(*PUBLIC_ENDPOINTS, *INTERNAL_ENDPOINTS) registry.addInterceptor(featureAuthorizationInterceptor) } @@ -157,6 +163,7 @@ class WebSecurityConfig( "/screenshots/**", "/uploaded-images/**", ) + private val ADMIN_ENDPOINTS = arrayOf("/v2/administration/**", "/v2/ee-license/**") private val INTERNAL_ENDPOINTS = arrayOf("/internal/**") } } 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..c220577d41 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 + authentication.isSuperToken } } } @@ -264,7 +264,7 @@ class V2UserControllerTest : AuthorizedControllerTest() { ).andIsOk.andAssertThatJson { node("accessToken").isString.satisfies { token: String -> val authentication = jwtService.validateToken(token) - authentication.details?.isSuperToken == true + authentication.isSuperToken } } } 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 c96b4aa9e9..b5dd5cd4e0 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt @@ -153,11 +153,12 @@ class LanguageServiceTest : AbstractSpringTest() { private fun setAuthentication(user: UserAccount) { SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - UserAccountDto.fromEntity(user), + credentials = null, + deviceId = null, + userAccount = UserAccountDto.fromEntity(user), actingAsUserAccount = null, readOnly = false, - details = null, + 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 02e1680b2f..d547cc08f9 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 @@ -122,11 +122,12 @@ class ImportServiceTest : AbstractSpringTest() { testDataService.saveTestData(testData.root) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - UserAccountDto.fromEntity(testData.userAccount), + credentials = null, + deviceId = null, + userAccount = UserAccountDto.fromEntity(testData.userAccount), actingAsUserAccount = null, readOnly = false, - details = TolgeeAuthenticationDetails(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 5d36241ae9..fc2cea898a 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 @@ -44,11 +44,12 @@ class StoredDataImporterTest : AbstractSpringTest() { fun login() { SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - UserAccountDto.fromEntity(importTestData.userAccount), + credentials = null, + deviceId = null, + userAccount = UserAccountDto.fromEntity(importTestData.userAccount), actingAsUserAccount = null, readOnly = false, - details = TolgeeAuthenticationDetails(false), + isSuperToken = false, ) } 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..72d9d0b165 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,8 @@ enum class Message { SUGGESTION_CANT_BE_PLURAL, SUGGESTION_MUST_BE_PLURAL, DUPLICATE_SUGGESTION, - UNSUPPORTED_MEDIA_TYPE, + IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED, ; val code: String 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 660517feea..f9544f5826 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 @@ -84,11 +84,13 @@ class AuthenticationFacade( get() = authentication.actingAsUserAccount // -- AUTHENTICATION METHOD AND DETAILS + val deviceId: String? + get() = authentication.deviceId val isReadOnly: Boolean get() = authentication.readOnly 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 8bbe61b691..1c267316b6 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,6 +28,7 @@ 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.exceptions.AuthExpiredException import io.tolgee.exceptions.AuthenticationException import io.tolgee.service.security.UserAccountService @@ -67,8 +68,8 @@ class JwtService( isSuper: Boolean = false, ): String { val now = currentDateProvider.date - val expiration = Date(now.time + authenticationProperties.jwtExpiration) + val builder = Jwts.builder() .signWith(signingKey) @@ -81,6 +82,9 @@ class JwtService( builder.claim(JWT_TOKEN_ACTING_USER_ID_CLAIM, actingAsUserAccountId) } + val deviceId = UUID.randomUUID().toString() + builder.claim(JWT_TOKEN_DEVICE_ID_CLAIM, deviceId) + if (isReadOnly) { builder.claim(JWT_TOKEN_READ_ONLY_CLAIM, true) } @@ -159,17 +163,27 @@ class JwtService( 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 read-only admin impersonation to make our lives easier + throw AuthenticationException(Message.INVALID_JWT_TOKEN) + } + return TolgeeAuthentication( credentials = jws, + deviceId = deviceId, userAccount = account, actingAsUserAccount = actor, readOnly = roClaim, - details = TolgeeAuthenticationDetails(hasSuperPowers), + isSuperToken = hasSuperPowers, ) } @@ -251,6 +265,7 @@ class JwtService( 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 a015e5100c..ec0fa69810 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,10 +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, + /** + * 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 readOnly: Boolean, - private val details: TolgeeAuthenticationDetails?, + /** + * Whether the user is super-authenticated + */ + val isSuperToken: Boolean = false, + private val details: TolgeeAuthenticationDetails? = null, ) : Authentication { var userAccountEntity: UserAccount? = null var userAccountView: UserAccountView? = null @@ -59,9 +74,14 @@ class TolgeeAuthentication( SimpleGrantedAuthority(ROLE_ADMIN), ) null -> emptyList() - } + } + readOnlyAsAuthority } + private val readOnlyAsAuthority: GrantedAuthority + get() { + return SimpleGrantedAuthority( if (readOnly) { ROLE_RO } else { ROLE_RW }) + } + override fun getCredentials(): Any? { return credentials } @@ -86,5 +106,7 @@ class TolgeeAuthentication( const val ROLE_USER = "ROLE_USER" const val ROLE_SUPPORTER = "ROLE_SUPPORTER" const val ROLE_ADMIN = "ROLE_ADMIN" + const val ROLE_RO = "ROLE_RO" + const val ROLE_RW = "ROLE_RW" } } 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 f10c33e4e6..0bc7d239a6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt @@ -83,10 +83,11 @@ class StartupImportService( SecurityContextHolder.getContext().authentication = TolgeeAuthentication( credentials = null, + deviceId = null, userAccount = UserAccountDto.fromEntity(userAccount), actingAsUserAccount = null, readOnly = false, - details = TolgeeAuthenticationDetails(false), + isSuperToken = false, ) } 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 b70cb207b7..01c6e55c0d 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 @@ -473,14 +473,10 @@ class PermissionService( } @Transactional - fun setOrganizationBasePermissions( + fun removeDirectProjectPermissions( projectId: Long, userId: Long, ) { - val project = projectService.get(projectId) - // organizationRoleService.checkUserIsMember(userId, project.organizationOwner.id) - // FIXME: this check shouldn't be here, no? In the doc string it says the user will have no permissions if they - // are not part of the organization, so we should allow this even when the user is not a member. val permission = getProjectPermissionData(projectId, userId).directPermissions ?: return delete(permission.id) } 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 60caa2fb57..d67d0f297f 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 @@ -50,6 +50,8 @@ 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 jwtService: JwtService = @@ -110,8 +112,8 @@ class JwtServiceTest { 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 @@ -122,7 +124,7 @@ class JwtServiceTest { 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 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..1f217ba6db --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt @@ -0,0 +1,59 @@ +/** + * 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.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 + +/** + * Blocks write requests when the current authentication is read-only. + * Annotate class or method with [AllowInReadOnlyMode] 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 + } + + if (isReadOnlyMethod(request, handler)) { + // These methods should be read-only - safe to call from read-only mode + return true + } + + throw PermissionException(Message.OPERATION_NOT_PERMITTED) + } +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt new file mode 100644 index 0000000000..7669b2e990 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt @@ -0,0 +1,27 @@ +/** + * 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 controller or handler method. + * + * When current authentication has readOnly flag set, only GET/HEAD/OPTIONS HTTP methods are allowed by default. + * Applying this annotation to a controller class or method allows non-safe methods as well. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class AllowInReadOnlyMode 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 4c7d0514c2..2e6581fa99 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,11 +115,12 @@ class AuthenticationFilter( if (!authenticationProperties.enabled) { SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - null, - initialUser, - null, - false, - TolgeeAuthenticationDetails(true), + credentials = null, + deviceId = null, + userAccount = initialUser, + actingAsUserAccount = null, + readOnly = false, + isSuperToken = true, ) } } @@ -165,11 +166,12 @@ class AuthenticationFilter( apiKeyService.updateLastUsedAsync(pak.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - pak, - userAccount, - null, - false, - TolgeeAuthenticationDetails(false), + credentials = pak, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = null, + readOnly = false, + isSuperToken = false, ) } @@ -192,11 +194,12 @@ class AuthenticationFilter( patService.updateLastUsedAsync(pat.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( - pat, - userAccount, - null, - false, - TolgeeAuthenticationDetails(false), + credentials = pat, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = null, + readOnly = false, + isSuperToken = false, ) } 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..e661757c8d --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -0,0 +1,61 @@ +/** + * 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.DispatcherType +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.core.Ordered +import org.springframework.core.annotation.AnnotationUtils +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.HandlerInterceptor + +/** + * Blocks write requests when the current authentication is read-only. + * Annotate class or method with [AllowInReadOnlyMode] to override. + */ +@Component +class ReadOnlyModeInterceptor( + 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 + } + + if (!authenticationFacade.isReadOnly) { + // If not in read-only mode, allow + return true + } + + if (isReadOnlyMethod(request, handler)) { + // These methods should be read-only - safe to call from read-only mode + return true + } + + throw PermissionException(Message.OPERATION_NOT_PERMITTED) + } +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt new file mode 100644 index 0000000000..6fc4fe49dc --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt @@ -0,0 +1,27 @@ +/** + * 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). + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) +@Retention(AnnotationRetention.RUNTIME) +@PreAuthorize("hasRole('RW')") +annotation class RequiresReadWriteMode 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..b19c6d3dc4 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 @@ -16,6 +16,8 @@ package io.tolgee.security.authorization +import io.tolgee.security.authentication.AllowInReadOnlyMode +import io.tolgee.security.authentication.RequiresReadWriteMode import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -23,8 +25,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 +45,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) } @@ -59,4 +64,20 @@ abstract class AbstractAuthorizationInterceptor : HandlerInterceptor, Ordered { val annotation = AnnotationUtils.getAnnotation(handler.method, IsGlobalRoute::class.java) return annotation != null } + + fun isReadOnlyMethod(request: HttpServletRequest, handler: HandlerMethod): Boolean { + if (AnnotationUtils.getAnnotation(handler.method, RequiresReadWriteMode::class.java) != null) { + return false + } + + if (request.method in READ_ONLY_METHODS) { + return true + } + + return AnnotationUtils.getAnnotation(handler.method, AllowInReadOnlyMode::class.java) != null + } + + companion object { + val READ_ONLY_METHODS = arrayOf("GET", "HEAD", "OPTIONS") + } } 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..5dbb139579 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 @@ -5,12 +5,15 @@ import io.tolgee.constants.Feature import io.tolgee.constants.Message import io.tolgee.exceptions.BadRequestException import io.tolgee.security.OrganizationHolder +import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory +import org.springframework.core.Ordered import org.springframework.core.annotation.AnnotationUtils import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.HandlerInterceptor /** * This interceptor checks if the required features are enabled for the organization. @@ -21,7 +24,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( 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 d6e1c751a8..d7a87e0f14 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,6 +16,7 @@ package io.tolgee.security.authorization +import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.hasAdminAccess import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException @@ -63,6 +64,8 @@ class OrganizationAuthorizationInterceptor( var bypassed = false val requiredRole = getRequiredRole(request, handler) + val isReadOnlyMethod = isReadOnlyMethod(request, handler) + val isReadOnly = requiredRole?.isReadOnly != false || isReadOnlyMethod logger.debug( "Checking access to org#{} by user#{} (Requires {})", organization.id, @@ -71,7 +74,7 @@ class OrganizationAuthorizationInterceptor( ) if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { - if (!user.hasAdminAccess(isReadonlyAccess = true)) { + if (!user.hasAdminAccess(isReadonlyAccess = isReadOnlyMethod)) { logger.debug( "Rejecting access to org#{} for user#{} - No view permissions", organization.id, @@ -86,7 +89,7 @@ class OrganizationAuthorizationInterceptor( } if (requiredRole != null && !organizationRoleService.isUserOfRole(userId, organization.id, requiredRole)) { - if (!user.hasAdminAccess(isReadonlyAccess = requiredRole.isReadOnly)) { + if (!user.hasAdminAccess(isReadonlyAccess = isReadOnly)) { logger.debug( "Rejecting access to org#{} for user#{} - Insufficient role", organization.id, @@ -99,6 +102,17 @@ class OrganizationAuthorizationInterceptor( bypassed = true } + if (authenticationFacade.isReadOnly && !isReadOnly) { + // This one can't be bypassed + logger.debug( + "Rejecting access to org#{} for user#{} - Write operation is not allowed in read-only mode", + organization.id, + userId, + ) + + throw PermissionException() + } + if (bypassed) { logger.info( "Use of admin privileges: user#{} failed local security checks for org#{} - bypassing for {} {}", 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 0b2839e407..8fc0792ec2 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 @@ -67,6 +67,8 @@ class ProjectAuthorizationInterceptor( var bypassed = false val requiredScopes = getRequiredScopes(request, handler) + val isReadOnlyMethod = isReadOnlyMethod(request, handler) + val isReadOnly = Scope.areAllReadOnly(requiredScopes) || isReadOnlyMethod val formattedRequirements = requiredScopes?.joinToString(", ") { it.value } ?: "read-only" logger.debug("Checking access to proj#${project.id} by user#$userId (Requires $formattedRequirements)") @@ -74,7 +76,7 @@ class ProjectAuthorizationInterceptor( val scopes = securityService.getCurrentPermittedScopes(project.id) if (scopes.isEmpty()) { - if (!user.hasAdminAccess(isReadonlyAccess = true)) { + if (!user.hasAdminAccess(isReadonlyAccess = isReadOnlyMethod)) { logger.debug( "Rejecting access to proj#{} for user#{} - No view permissions", project.id, @@ -88,10 +90,10 @@ class ProjectAuthorizationInterceptor( bypassed = true } - val missingScopes = getMissingScopes(requiredScopes, scopes.toSet()) + val missingScopes = getMissingScopes(requiredScopes, scopes) if (missingScopes.isNotEmpty()) { - val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = Scope.areAllReadOnly(requiredScopes)) + val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = isReadOnly) val canUseAdminRights = !authenticationFacade.isProjectApiKeyAuth val canBypass = hasAdminAccess && canUseAdminRights if (!canBypass) { @@ -125,6 +127,17 @@ class ProjectAuthorizationInterceptor( } } + if (authenticationFacade.isReadOnly && !isReadOnly) { + // This one can't be bypassed + logger.debug( + "Rejecting access to proj#{} for user#{} - Write operation is not allowed in read-only mode", + project.id, + userId, + ) + + throw PermissionException() + } + if (bypassed) { logger.info( "Use of admin privileges: user#{} failed local security checks for proj#{} - bypassing for {} {}", 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 8167471282..af38d63d85 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,11 +132,12 @@ class AuthenticationFilterTest { Mockito.`when`(jwtService.validateToken(TEST_VALID_TOKEN)) .thenReturn( TolgeeAuthentication( - "uwu", - userAccountDto, - null, - false, - null, + credentials = "uwu", + deviceId = null, + userAccount = userAccountDto, + actingAsUserAccount = null, + readOnly = false, + isSuperToken = false, ), ) From 2e55e3d76ad53897a8f100f8b1a3290c0e214ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 23 Sep 2025 15:03:41 +0200 Subject: [PATCH 04/29] chore: fix lint --- .../io/tolgee/service/dataImport/ImportServiceTest.kt | 1 - .../tolgee/service/dataImport/StoredDataImporterTest.kt | 1 - .../security/authentication/TolgeeAuthentication.kt | 8 +++++++- .../main/kotlin/io/tolgee/service/StartupImportService.kt | 1 - .../security/authentication/ReadOnlyModeInterceptor.kt | 4 ---- .../authorization/FeatureAuthorizationInterceptor.kt | 3 --- .../authorization/OrganizationAuthorizationInterceptor.kt | 1 - 7 files changed, 7 insertions(+), 12 deletions(-) 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 d547cc08f9..5a95ece6a1 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 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 fc2cea898a..36a9c15bd7 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 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 ec0fa69810..75dbcc15c1 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 @@ -79,7 +79,13 @@ class TolgeeAuthentication( private val readOnlyAsAuthority: GrantedAuthority get() { - return SimpleGrantedAuthority( if (readOnly) { ROLE_RO } else { ROLE_RW }) + return SimpleGrantedAuthority( + if (readOnly) { + ROLE_RO + } else { + ROLE_RW + } + ) } override fun getCredentials(): Any? { 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 0bc7d239a6..8df208ba0e 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 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 index e661757c8d..1e71b2bb53 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -19,14 +19,10 @@ package io.tolgee.security.authentication import io.tolgee.constants.Message import io.tolgee.exceptions.PermissionException import io.tolgee.security.authorization.AbstractAuthorizationInterceptor -import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.springframework.core.Ordered -import org.springframework.core.annotation.AnnotationUtils import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod -import org.springframework.web.servlet.HandlerInterceptor /** * Blocks write requests when the current authentication is read-only. 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 5dbb139579..e77c64c5a2 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 @@ -5,15 +5,12 @@ import io.tolgee.constants.Feature import io.tolgee.constants.Message import io.tolgee.exceptions.BadRequestException import io.tolgee.security.OrganizationHolder -import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory -import org.springframework.core.Ordered import org.springframework.core.annotation.AnnotationUtils import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod -import org.springframework.web.servlet.HandlerInterceptor /** * This interceptor checks if the required features are enabled for the organization. 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 d7a87e0f14..7b89d48774 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,7 +16,6 @@ package io.tolgee.security.authorization -import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.hasAdminAccess import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException From 08695b52f465166d10ef3a21a848f1c2214a68cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Mon, 29 Sep 2025 17:16:45 +0200 Subject: [PATCH 05/29] feat: implement supporter role on FE side + fixes; --- .../controllers/AdministrationController.kt | 6 ++++ .../v2/controllers/InitialDataController.kt | 3 ++ .../io/tolgee/hateoas/auth/AuthInfoModel.kt | 7 ++++ .../hateoas/auth/AuthInfoModelAssembler.kt | 18 ++++++++++ .../hateoas/initialData/InitialDataModel.kt | 2 ++ .../io/tolgee/service/LanguageServiceTest.kt | 2 +- .../service/dataImport/ImportServiceTest.kt | 2 +- .../dataImport/StoredDataImporterTest.kt | 2 +- .../kotlin/io/tolgee/constants/Message.kt | 1 + .../io/tolgee/repository/ProjectRepository.kt | 2 +- .../authentication/AuthenticationFacade.kt | 2 +- .../security/authentication/JwtService.kt | 2 +- .../authentication/TolgeeAuthentication.kt | 8 ++--- .../io/tolgee/service/StartupImportService.kt | 2 +- .../authentication/AuthenticationFilter.kt | 6 ++-- .../AuthenticationFilterTest.kt | 2 +- e2e/cypress/e2e/administration/base.cy.ts | 2 +- .../common/useFeatureMissingExplanation.tsx | 5 ++- .../AdministrationAccessAnnouncement.tsx | 11 ++++++- .../DebuggingCustomerAccountAnnouncement.tsx | 19 ++++++++--- .../UserMenu/UserPresentAvatarMenu.tsx | 4 ++- .../component/UserMenu/BillingMenuItem.tsx | 3 +- webapp/src/globalContext/helpers.tsx | 11 +++++++ webapp/src/service/apiSchema.generated.ts | 33 ++++++++++++++----- .../components/RoleSelector.tsx | 9 +++-- .../organizations/OrganizationsRouter.tsx | 8 +++-- .../BaseOrganizationSettingsView.tsx | 6 ++-- webapp/src/views/projects/ProjectListView.tsx | 10 ++++-- webapp/src/views/projects/ProjectPage.tsx | 4 ++- 29 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModel.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/auth/AuthInfoModelAssembler.kt diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt index 3446e7df05..63f29ed9f1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt @@ -149,6 +149,12 @@ class AdministrationController( fun generateUserToken( @PathVariable userId: Long, ): String { + 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.get(userId) val isAdmin = actingUser.isAdmin() 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/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/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt index b5dd5cd4e0..d6dbd263a9 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/LanguageServiceTest.kt @@ -157,7 +157,7 @@ class LanguageServiceTest : AbstractSpringTest() { deviceId = null, userAccount = UserAccountDto.fromEntity(user), actingAsUserAccount = null, - readOnly = false, + 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 5a95ece6a1..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 @@ -125,7 +125,7 @@ class ImportServiceTest : AbstractSpringTest() { deviceId = null, userAccount = UserAccountDto.fromEntity(testData.userAccount), actingAsUserAccount = null, - readOnly = false, + 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 36a9c15bd7..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 @@ -47,7 +47,7 @@ class StoredDataImporterTest : AbstractSpringTest() { deviceId = null, userAccount = UserAccountDto.fromEntity(importTestData.userAccount), actingAsUserAccount = null, - readOnly = false, + isReadOnly = false, isSuperToken = false, ) } 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 72d9d0b165..93d686b5c5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -309,6 +309,7 @@ enum class Message { DUPLICATE_SUGGESTION, UNSUPPORTED_MEDIA_TYPE, IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED, + ALREADY_IMPERSONATING_USER, ; val code: String 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 f9544f5826..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 @@ -87,7 +87,7 @@ class AuthenticationFacade( val deviceId: String? get() = authentication.deviceId val isReadOnly: Boolean - get() = authentication.readOnly + get() = authentication.isReadOnly val isUserSuperAuthenticated: Boolean get() = if (isAuthenticated) authentication.isSuperToken else false 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 1c267316b6..ae285f4cea 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 @@ -182,7 +182,7 @@ class JwtService( deviceId = deviceId, userAccount = account, actingAsUserAccount = actor, - readOnly = roClaim, + isReadOnly = roClaim, isSuperToken = hasSuperPowers, ) } 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 75dbcc15c1..67609971aa 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 @@ -40,7 +40,7 @@ class TolgeeAuthentication( /** * Whether the token can be used only for read-only requests. */ - val readOnly: Boolean, + val isReadOnly: Boolean, /** * Whether the user is super-authenticated */ @@ -74,13 +74,13 @@ class TolgeeAuthentication( SimpleGrantedAuthority(ROLE_ADMIN), ) null -> emptyList() - } + readOnlyAsAuthority + } + authorityFromIsReadOnly } - private val readOnlyAsAuthority: GrantedAuthority + private val authorityFromIsReadOnly: GrantedAuthority get() { return SimpleGrantedAuthority( - if (readOnly) { + if (isReadOnly) { ROLE_RO } else { ROLE_RW 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 8df208ba0e..9e03d59b69 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt @@ -85,7 +85,7 @@ class StartupImportService( deviceId = null, userAccount = UserAccountDto.fromEntity(userAccount), actingAsUserAccount = null, - readOnly = false, + isReadOnly = false, isSuperToken = false, ) } 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 2e6581fa99..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 @@ -119,7 +119,7 @@ class AuthenticationFilter( deviceId = null, userAccount = initialUser, actingAsUserAccount = null, - readOnly = false, + isReadOnly = false, isSuperToken = true, ) } @@ -170,7 +170,7 @@ class AuthenticationFilter( deviceId = null, userAccount = userAccount, actingAsUserAccount = null, - readOnly = false, + isReadOnly = false, isSuperToken = false, ) } @@ -198,7 +198,7 @@ class AuthenticationFilter( deviceId = null, userAccount = userAccount, actingAsUserAccount = null, - readOnly = false, + isReadOnly = false, isSuperToken = false, ) } 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 af38d63d85..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 @@ -136,7 +136,7 @@ class AuthenticationFilterTest { deviceId = null, userAccount = userAccountDto, actingAsUserAccount = null, - readOnly = false, + isReadOnly = false, isSuperToken = false, ), ) diff --git a/e2e/cypress/e2e/administration/base.cy.ts b/e2e/cypress/e2e/administration/base.cy.ts index 912f5daaa3..3be1dd1fef 100644 --- a/e2e/cypress/e2e/administration/base.cy.ts +++ b/e2e/cypress/e2e/administration/base.cy.ts @@ -115,7 +115,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/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 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/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index d3662e2f53..9525ce7cf5 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -897,7 +897,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 */ @@ -1175,6 +1175,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. @@ -1219,6 +1220,9 @@ export interface components { userFullName?: string; username?: string; }; + AuthInfoModel: { + isReadOnly: boolean; + }; AuthMethodsDTO: { github: components["schemas"]["OAuthPublicConfigDTO"]; google: components["schemas"]["OAuthPublicConfigDTO"]; @@ -1543,7 +1547,8 @@ export interface components { | "DIRECT" | "ORGANIZATION_OWNER" | "NONE" - | "SERVER_ADMIN"; + | "SERVER_ADMIN" + | "SERVER_SUPPORTER"; permissionModel?: components["schemas"]["PermissionModel"]; /** * @deprecated @@ -1591,6 +1596,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. @@ -2442,7 +2448,9 @@ 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"; params?: unknown[]; }; ExistenceEntityDescription: { @@ -2682,7 +2690,8 @@ export interface components { | "prompts.view" | "prompts.edit" | "translation-labels.manage" - | "translation-labels.assign"; + | "translation-labels.assign" + | "all.view"; }; IdentifyRequest: { anonymousUserId: string; @@ -2940,6 +2949,7 @@ export interface components { }; InitialDataModel: { announcement?: components["schemas"]["AnnouncementDto"]; + authInfo?: components["schemas"]["AuthInfoModel"]; eeSubscription?: components["schemas"]["InitialDataEeSubscriptionModel"]; languageTag?: string; preferredOrganization?: components["schemas"]["PrivateOrganizationModel"]; @@ -3990,6 +4000,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. @@ -4062,6 +4073,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. @@ -4176,7 +4188,7 @@ export interface components { deletable: boolean; domain?: string; emailAwaitingVerification?: string; - globalServerRole: "USER" | "ADMIN"; + globalServerRole: "USER" | "ADMIN" | "SUPPORTER"; /** Format: int64 */ id: number; mfaEnabled: boolean; @@ -5514,7 +5526,9 @@ 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"; params?: unknown[]; success: boolean; }; @@ -5943,7 +5957,7 @@ export interface components { deleted: boolean; disabled: boolean; emailAwaitingVerification?: string; - globalServerRole: "USER" | "ADMIN"; + globalServerRole: "USER" | "ADMIN" | "SUPPORTER"; /** Format: int64 */ id: number; mfaEnabled: boolean; @@ -6733,7 +6747,7 @@ export interface operations { parameters: { path: { userId: number; - role: "USER" | "ADMIN"; + role: "USER" | "ADMIN" | "SUPPORTER"; }; }; responses: { @@ -18762,7 +18776,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; @@ -19478,6 +19492,7 @@ export interface operations { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; }; }; 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 ( Date: Tue, 30 Sep 2025 14:11:23 +0200 Subject: [PATCH 06/29] fix: add missing permission to the hierarchy --- .../component/PermissionsSettings/usePermissionsStructure.ts | 3 +++ 1 file changed, 3 insertions(+) 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: [ From fed70dd6d6f6f35dbd5c2a14e9c661d55de4ff80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Oct 2025 14:22:27 +0200 Subject: [PATCH 07/29] fix: tests for new interceptors --- .../authentication/AdminAccessInterceptor.kt | 6 +- .../OrganizationAuthorizationInterceptor.kt | 2 +- .../AdminAccessInterceptorTest.kt | 262 ++++++++++++++++++ .../ReadOnlyModeInterceptorTest.kt | 201 ++++++++++++++ 4 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 backend/security/src/test/kotlin/io/tolgee/security/authentication/AdminAccessInterceptorTest.kt create mode 100644 backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt 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 index 1f217ba6db..97108d1699 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt @@ -18,6 +18,7 @@ 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 @@ -49,8 +50,9 @@ class AdminAccessInterceptor( return true } - if (isReadOnlyMethod(request, handler)) { - // These methods should be read-only - safe to call from read-only mode + val hasReadAccess = authenticationFacade.authenticatedUser.isSupporterOrAdmin() + if (hasReadAccess && isReadOnlyMethod(request, handler)) { + // These methods should be read-only - safe to call return true } 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 7b89d48774..928c64657a 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 @@ -33,7 +33,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 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..305558ebd9 --- /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 RequiresReadWriteMode`() { + 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 RequiresReadWriteMode`() { + 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" + + @RequiresReadWriteMode + @GetMapping("/admin/read-requires-rw") + fun readRequiresRw() = "ok" + + @AllowInReadOnlyMode + @PostMapping("/admin/write-allowed") + fun writeAllowed() = "ok" + + @AllowInReadOnlyMode + @PutMapping("/admin/write-allowed") + fun putAllowed() = "ok" + + @AllowInReadOnlyMode + @PatchMapping("/admin/write-allowed") + fun patchAllowed() = "ok" + + @AllowInReadOnlyMode + @DeleteMapping("/admin/write-allowed") + fun deleteAllowed() = "ok" + } +} 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..df520abff8 --- /dev/null +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt @@ -0,0 +1,201 @@ +/** + * 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.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.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 RequiresReadWriteMode`() { + mockMvc.perform(get("/test/read-requires-rw")).andIsForbidden + } + + @Test + fun `it denies HEAD annotated with RequiresReadWriteMode`() { + 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" + + @AllowInReadOnlyMode + @PostMapping("/test/write-allowed") + fun writeAllowed() = "ok" + + @AllowInReadOnlyMode + @PutMapping("/test/write-allowed") + fun putAllowed() = "ok" + + @AllowInReadOnlyMode + @PatchMapping("/test/write-allowed") + fun patchAllowed() = "ok" + + @AllowInReadOnlyMode + @DeleteMapping("/test/write-allowed") + fun deleteAllowed() = "ok" + + @RequiresReadWriteMode + @GetMapping("/test/read-requires-rw") + fun readRequiresRw() = "ok" + } +} From 737810f617be44e1d0e38b94a2fe229d8d3d084b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Oct 2025 14:51:24 +0200 Subject: [PATCH 08/29] fix: make sure override annotations won't be ignored --- .../AbstractAuthorizationInterceptor.kt | 15 +++++++++++++-- .../OrganizationAuthorizationInterceptor.kt | 11 ++++++++--- .../ProjectAuthorizationInterceptor.kt | 7 ++++--- 3 files changed, 25 insertions(+), 8 deletions(-) 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 b19c6d3dc4..5e71d5f12f 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 @@ -65,12 +65,23 @@ abstract class AbstractAuthorizationInterceptor( return annotation != null } - fun isReadOnlyMethod(request: HttpServletRequest, handler: HandlerMethod): Boolean { + /** + * Determines if the target endpoint is read-only. Can be overridden by annotating the method with + * [AllowInReadOnlyMode] or [RequiresReadWriteMode] annotation. + * + * @param usesWritePermissions whether the request uses write permissions; if false, the method is + * considered read-only; ignored if null + */ + fun isReadOnlyMethod( + request: HttpServletRequest, + handler: HandlerMethod, + usesWritePermissions: Boolean? = null + ): Boolean { if (AnnotationUtils.getAnnotation(handler.method, RequiresReadWriteMode::class.java) != null) { return false } - if (request.method in READ_ONLY_METHODS) { + if (request.method in READ_ONLY_METHODS || usesWritePermissions == false) { return true } 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 928c64657a..b987e8e85e 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 @@ -64,7 +64,12 @@ class OrganizationAuthorizationInterceptor( var bypassed = false val requiredRole = getRequiredRole(request, handler) val isReadOnlyMethod = isReadOnlyMethod(request, handler) - val isReadOnly = requiredRole?.isReadOnly != false || isReadOnlyMethod + val isReadOnlyOperation = isReadOnlyMethod( + request, + handler, + usesWritePermissions = + requiredRole?.isReadOnly == false + ) logger.debug( "Checking access to org#{} by user#{} (Requires {})", organization.id, @@ -88,7 +93,7 @@ class OrganizationAuthorizationInterceptor( } if (requiredRole != null && !organizationRoleService.isUserOfRole(userId, organization.id, requiredRole)) { - if (!user.hasAdminAccess(isReadonlyAccess = isReadOnly)) { + if (!user.hasAdminAccess(isReadonlyAccess = isReadOnlyOperation)) { logger.debug( "Rejecting access to org#{} for user#{} - Insufficient role", organization.id, @@ -101,7 +106,7 @@ class OrganizationAuthorizationInterceptor( bypassed = true } - if (authenticationFacade.isReadOnly && !isReadOnly) { + if (authenticationFacade.isReadOnly && !isReadOnlyOperation) { // This one can't be bypassed logger.debug( "Rejecting access to org#{} for user#{} - Write operation is not allowed in read-only mode", 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 8fc0792ec2..cdacc20b0c 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 @@ -68,7 +68,8 @@ class ProjectAuthorizationInterceptor( var bypassed = false val requiredScopes = getRequiredScopes(request, handler) val isReadOnlyMethod = isReadOnlyMethod(request, handler) - val isReadOnly = Scope.areAllReadOnly(requiredScopes) || isReadOnlyMethod + val isReadOnlyOperation = + isReadOnlyMethod(request, handler, usesWritePermissions = !Scope.areAllReadOnly(requiredScopes)) val formattedRequirements = requiredScopes?.joinToString(", ") { it.value } ?: "read-only" logger.debug("Checking access to proj#${project.id} by user#$userId (Requires $formattedRequirements)") @@ -93,7 +94,7 @@ class ProjectAuthorizationInterceptor( val missingScopes = getMissingScopes(requiredScopes, scopes) if (missingScopes.isNotEmpty()) { - val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = isReadOnly) + val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = isReadOnlyOperation) val canUseAdminRights = !authenticationFacade.isProjectApiKeyAuth val canBypass = hasAdminAccess && canUseAdminRights if (!canBypass) { @@ -127,7 +128,7 @@ class ProjectAuthorizationInterceptor( } } - if (authenticationFacade.isReadOnly && !isReadOnly) { + if (authenticationFacade.isReadOnly && !isReadOnlyOperation) { // This one can't be bypassed logger.debug( "Rejecting access to proj#{} for user#{} - Write operation is not allowed in read-only mode", From 0a748d3e7b2e68f5c9ebd0ca0a7fcf34ad222314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Oct 2025 18:48:10 +0200 Subject: [PATCH 09/29] fix: updated tests for existing interceptors + fixes and readability improvements --- .../tolgee/security/ServerAdminFilterTest.kt | 14 ++ .../authentication/AdminAccessInterceptor.kt | 2 +- .../authentication/ReadOnlyModeInterceptor.kt | 2 +- ...InReadOnlyMode.kt => ReadOnlyOperation.kt} | 2 +- ...iresReadWriteMode.kt => WriteOperation.kt} | 2 +- .../AbstractAuthorizationInterceptor.kt | 22 ++- .../OrganizationAuthorizationInterceptor.kt | 21 +-- .../ProjectAuthorizationInterceptor.kt | 27 ++-- .../AdminAccessInterceptorTest.kt | 14 +- .../ReadOnlyModeInterceptorTest.kt | 14 +- ...rganizationAuthorizationInterceptorTest.kt | 133 +++++++++++++++-- .../ProjectAuthorizationInterceptorTest.kt | 136 +++++++++++++++++- 12 files changed, 330 insertions(+), 59 deletions(-) rename backend/security/src/main/kotlin/io/tolgee/security/authentication/{AllowInReadOnlyMode.kt => ReadOnlyOperation.kt} (96%) rename backend/security/src/main/kotlin/io/tolgee/security/authentication/{RequiresReadWriteMode.kt => WriteOperation.kt} (96%) 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/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt index 97108d1699..4d10b86379 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt @@ -28,7 +28,7 @@ import org.springframework.web.method.HandlerMethod /** * Blocks write requests when the current authentication is read-only. - * Annotate class or method with [AllowInReadOnlyMode] to override. + * Annotate class or method with [ReadOnlyOperation] to override. */ @Component class AdminAccessInterceptor( 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 index 1e71b2bb53..80177a2a4e 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -26,7 +26,7 @@ import org.springframework.web.method.HandlerMethod /** * Blocks write requests when the current authentication is read-only. - * Annotate class or method with [AllowInReadOnlyMode] to override. + * Annotate class or method with [ReadOnlyOperation] to override. */ @Component class ReadOnlyModeInterceptor( diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyOperation.kt similarity index 96% rename from backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt rename to backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyOperation.kt index 7669b2e990..1e5c0d180d 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AllowInReadOnlyMode.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyOperation.kt @@ -24,4 +24,4 @@ package io.tolgee.security.authentication */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) -annotation class AllowInReadOnlyMode +annotation class ReadOnlyOperation diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/WriteOperation.kt similarity index 96% rename from backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt rename to backend/security/src/main/kotlin/io/tolgee/security/authentication/WriteOperation.kt index 6fc4fe49dc..81a30504ed 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/RequiresReadWriteMode.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/WriteOperation.kt @@ -24,4 +24,4 @@ import org.springframework.security.access.prepost.PreAuthorize @Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) @Retention(AnnotationRetention.RUNTIME) @PreAuthorize("hasRole('RW')") -annotation class RequiresReadWriteMode +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 5e71d5f12f..0d9de78d1e 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 @@ -16,8 +16,8 @@ package io.tolgee.security.authorization -import io.tolgee.security.authentication.AllowInReadOnlyMode -import io.tolgee.security.authentication.RequiresReadWriteMode +import io.tolgee.security.authentication.ReadOnlyOperation +import io.tolgee.security.authentication.WriteOperation import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -67,7 +67,7 @@ abstract class AbstractAuthorizationInterceptor( /** * Determines if the target endpoint is read-only. Can be overridden by annotating the method with - * [AllowInReadOnlyMode] or [RequiresReadWriteMode] annotation. + * [ReadOnlyOperation] or [WriteOperation] annotation. * * @param usesWritePermissions whether the request uses write permissions; if false, the method is * considered read-only; ignored if null @@ -77,15 +77,25 @@ abstract class AbstractAuthorizationInterceptor( handler: HandlerMethod, usesWritePermissions: Boolean? = null ): Boolean { - if (AnnotationUtils.getAnnotation(handler.method, RequiresReadWriteMode::class.java) != null) { + val forceReadOnly = AnnotationUtils.getAnnotation(handler.method, ReadOnlyOperation::class.java) != null + val forceWrite = AnnotationUtils.getAnnotation(handler.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 (request.method in READ_ONLY_METHODS || usesWritePermissions == false) { + if (forceReadOnly) { return true } - return AnnotationUtils.getAnnotation(handler.method, AllowInReadOnlyMode::class.java) != null + return request.method in READ_ONLY_METHODS || usesWritePermissions == false } companion object { 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 b987e8e85e..ab5dfe1072 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 @@ -57,19 +57,18 @@ 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 requiredRole = getRequiredRole(request, handler) - val isReadOnlyMethod = isReadOnlyMethod(request, handler) val isReadOnlyOperation = isReadOnlyMethod( request, handler, - usesWritePermissions = - requiredRole?.isReadOnly == false + usesWritePermissions = requiredRole?.isReadOnly == false ) + val canBypass = user.hasAdminAccess(isReadonlyAccess = isReadOnlyOperation) logger.debug( "Checking access to org#{} by user#{} (Requires {})", organization.id, @@ -78,22 +77,28 @@ class OrganizationAuthorizationInterceptor( ) if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { - if (!user.hasAdminAccess(isReadonlyAccess = isReadOnlyMethod)) { + if (!canBypass) { 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() + val canBypassReadOnly = user.hasAdminAccess(isReadonlyAccess = true) + if (!canBypassReadOnly) { + // 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 (!user.hasAdminAccess(isReadonlyAccess = isReadOnlyOperation)) { + if (!canBypass) { logger.debug( "Rejecting access to org#{} for user#{} - Insufficient role", organization.id, 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 cdacc20b0c..1211ae4a92 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 @@ -37,7 +37,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( @@ -67,9 +67,10 @@ class ProjectAuthorizationInterceptor( var bypassed = false val requiredScopes = getRequiredScopes(request, handler) - val isReadOnlyMethod = isReadOnlyMethod(request, handler) - val isReadOnlyOperation = - isReadOnlyMethod(request, handler, usesWritePermissions = !Scope.areAllReadOnly(requiredScopes)) + val isReadOnly = isReadOnlyMethod(request, handler, usesWritePermissions = !Scope.areAllReadOnly(requiredScopes)) + val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = isReadOnly) + val canUseAdminRights = !authenticationFacade.isProjectApiKeyAuth + val canBypass = hasAdminAccess && canUseAdminRights val formattedRequirements = requiredScopes?.joinToString(", ") { it.value } ?: "read-only" logger.debug("Checking access to proj#${project.id} by user#$userId (Requires $formattedRequirements)") @@ -77,15 +78,22 @@ class ProjectAuthorizationInterceptor( val scopes = securityService.getCurrentPermittedScopes(project.id) if (scopes.isEmpty()) { - if (!user.hasAdminAccess(isReadonlyAccess = isReadOnlyMethod)) { + if (!canBypass) { 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) + val hasReadOnlyAdminAccess = user.hasAdminAccess(isReadonlyAccess = true) + val canBypassReadOnly = hasReadOnlyAdminAccess && canUseAdminRights + if (!canBypassReadOnly) { + // 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 @@ -94,9 +102,6 @@ class ProjectAuthorizationInterceptor( val missingScopes = getMissingScopes(requiredScopes, scopes) if (missingScopes.isNotEmpty()) { - val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = isReadOnlyOperation) - val canUseAdminRights = !authenticationFacade.isProjectApiKeyAuth - val canBypass = hasAdminAccess && canUseAdminRights if (!canBypass) { logger.debug( "Rejecting access to proj#{} for user#{} - Insufficient permissions", @@ -128,7 +133,7 @@ class ProjectAuthorizationInterceptor( } } - if (authenticationFacade.isReadOnly && !isReadOnlyOperation) { + if (authenticationFacade.isReadOnly && !isReadOnly) { // This one can't be bypassed logger.debug( "Rejecting access to proj#{} for user#{} - Write operation is not allowed in read-only mode", 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 index 305558ebd9..14db7826d5 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AdminAccessInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AdminAccessInterceptorTest.kt @@ -175,13 +175,13 @@ class AdminAccessInterceptorTest { } @Test - fun `it denies GET from supporter when method annotated with RequiresReadWriteMode`() { + 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 RequiresReadWriteMode`() { + 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 } @@ -239,23 +239,23 @@ class AdminAccessInterceptorTest { @DeleteMapping("/admin/write") fun delete() = "ok" - @RequiresReadWriteMode + @WriteOperation @GetMapping("/admin/read-requires-rw") fun readRequiresRw() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @PostMapping("/admin/write-allowed") fun writeAllowed() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @PutMapping("/admin/write-allowed") fun putAllowed() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @PatchMapping("/admin/write-allowed") fun patchAllowed() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @DeleteMapping("/admin/write-allowed") fun deleteAllowed() = "ok" } 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 index df520abff8..561155d9bc 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt @@ -128,12 +128,12 @@ class ReadOnlyModeInterceptorTest { } @Test - fun `it denies GET annotated with RequiresReadWriteMode`() { + fun `it denies GET annotated with WriteOperation`() { mockMvc.perform(get("/test/read-requires-rw")).andIsForbidden } @Test - fun `it denies HEAD annotated with RequiresReadWriteMode`() { + fun `it denies HEAD annotated with WriteOperation`() { mockMvc.perform(head("/test/read-requires-rw")).andIsForbidden } @@ -178,23 +178,23 @@ class ReadOnlyModeInterceptorTest { @DeleteMapping("/test/write") fun delete() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @PostMapping("/test/write-allowed") fun writeAllowed() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @PutMapping("/test/write-allowed") fun putAllowed() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @PatchMapping("/test/write-allowed") fun patchAllowed() = "ok" - @AllowInReadOnlyMode + @ReadOnlyOperation @DeleteMapping("/test/write-allowed") fun deleteAllowed() = "ok" - @RequiresReadWriteMode + @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..f6d25d0810 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,81 @@ 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 user with read-only token to access read-only organization endpoints`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(true) + Mockito.`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)).thenReturn(true) + Mockito.`when`(authentication.isReadOnly).thenReturn(true) + + performReadOnlyRequests { all -> all.andIsOk } + } + + @Test + fun `it does not let user with read-only token to access write organization endpoints`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(true) + Mockito.`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)).thenReturn(true) + Mockito.`when`(authentication.isReadOnly).thenReturn(true) + + 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 permissions + mockMvc.perform(post("/v2/organizations/1337/default-perms-write-method")).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 and write permissions + 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 +223,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}/requires-admin") + @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 requiresAdmin( @PathVariable id: Long, - ) = "henlo from org #$id!" + ) = "hello from org #$id!" + + @PostMapping("/v2/organizations/{id}/requires-owner-write-method") + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + fun requiresAdminWriteMethod( + @PathVariable id: Long, + ) = "hello from org #$id!" + + @GetMapping("/v2/organizations/{id}/requires-owner-write-annotation") + @WriteOperation + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + fun requiresAdminWriteAnnotation( + @PathVariable id: Long, + ) = "hello from org #$id!" + + @PostMapping("/v2/organizations/{id}/requires-owner-read-annotation") + @ReadOnlyOperation + @RequiresOrganizationRole(OrganizationRoleType.OWNER) + fun requiresAdminReadAnnotation( + @PathVariable id: Long, + ) = "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..34f04c7e6a 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,82 @@ 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 user with read-only token to access read-only project endpoints`() { + Mockito.`when`(authentication.isReadOnly).thenReturn(true) + + Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(setOf(Scope.KEYS_CREATE)) + + performReadOnlyRequests { all -> all.andIsOk } + } + + @Test + fun `it does not let user with read-only token to access write project endpoints`() { + Mockito.`when`(authentication.isReadOnly).thenReturn(true) + Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(setOf(Scope.KEYS_CREATE)) + + 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 permissions + mockMvc.perform(MockMvcRequestBuilders.post("/v2/projects/1337/default-perms-write-method")).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 and write permissions + 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 +341,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!" } } From f82763cc7914959ec7951bc7d445dca5ecfb6691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Oct 2025 12:42:48 +0200 Subject: [PATCH 10/29] fix: separate message for read only denied requests + don't filter project and organization endpoints with read only interceptor since other interceptors already handle that --- .../io/tolgee/configuration/WebSecurityConfig.kt | 13 ++++++++++--- .../src/main/kotlin/io/tolgee/constants/Message.kt | 1 + .../authentication/ReadOnlyModeInterceptor.kt | 2 +- .../OrganizationAuthorizationInterceptor.kt | 11 ++++++----- .../ProjectAuthorizationInterceptor.kt | 4 ++-- 5 files changed, 20 insertions(+), 11 deletions(-) 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 3bb0326e7b..4ff9abba37 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -134,13 +134,18 @@ class WebSecurityConfig( .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) + .excludePathPatterns( + *PUBLIC_ENDPOINTS, + *INTERNAL_ENDPOINTS, + *PROJECT_ENDPOINTS, + *ORGANIZATION_ENDPOINTS + ) registry.addInterceptor(featureAuthorizationInterceptor) } @@ -165,5 +170,7 @@ class WebSecurityConfig( ) 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/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 93d686b5c5..d792655277 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -310,6 +310,7 @@ enum class Message { 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/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt index 80177a2a4e..d5e3cf9091 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -52,6 +52,6 @@ class ReadOnlyModeInterceptor( return true } - throw PermissionException(Message.OPERATION_NOT_PERMITTED) + throw PermissionException(Message.OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE) } } 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 ab5dfe1072..59c0995e52 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,6 +16,7 @@ package io.tolgee.security.authorization +import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.hasAdminAccess import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException @@ -63,12 +64,12 @@ class OrganizationAuthorizationInterceptor( var bypassed = false val requiredRole = getRequiredRole(request, handler) - val isReadOnlyOperation = isReadOnlyMethod( + val isReadOnly = isReadOnlyMethod( request, handler, usesWritePermissions = requiredRole?.isReadOnly == false ) - val canBypass = user.hasAdminAccess(isReadonlyAccess = isReadOnlyOperation) + val canBypass = user.hasAdminAccess(isReadonlyAccess = isReadOnly) logger.debug( "Checking access to org#{} by user#{} (Requires {})", organization.id, @@ -111,15 +112,15 @@ class OrganizationAuthorizationInterceptor( bypassed = true } - if (authenticationFacade.isReadOnly && !isReadOnlyOperation) { - // This one can't be bypassed + if (authenticationFacade.isReadOnly && !isReadOnly) { + // This check can't be bypassed logger.debug( "Rejecting access to org#{} for user#{} - Write operation is not allowed in read-only mode", organization.id, userId, ) - throw PermissionException() + throw PermissionException(Message.OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE) } if (bypassed) { 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 1211ae4a92..757b416c3d 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 @@ -134,14 +134,14 @@ class ProjectAuthorizationInterceptor( } if (authenticationFacade.isReadOnly && !isReadOnly) { - // This one can't be bypassed + // This check can't be bypassed logger.debug( "Rejecting access to proj#{} for user#{} - Write operation is not allowed in read-only mode", project.id, userId, ) - throw PermissionException() + throw PermissionException(Message.OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE) } if (bypassed) { From 7bf1562a13e8b9eef1971a2898efa1b3acc8100b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Oct 2025 13:03:29 +0200 Subject: [PATCH 11/29] fix: log rejected requests by read only interceptor + better feature rejected requests logging --- .../authentication/ReadOnlyModeInterceptor.kt | 7 ++++ .../FeatureAuthorizationInterceptor.kt | 36 +++++++++---------- 2 files changed, 25 insertions(+), 18 deletions(-) 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 index d5e3cf9091..eb244a86f7 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -21,6 +21,7 @@ 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 @@ -32,6 +33,8 @@ import org.springframework.web.method.HandlerMethod class ReadOnlyModeInterceptor( private val authenticationFacade: AuthenticationFacade, ) : AbstractAuthorizationInterceptor(allowGlobalRoutes = false) { + private val logger = LoggerFactory.getLogger(this::class.java) + override fun preHandleInternal( request: HttpServletRequest, response: HttpServletResponse, @@ -52,6 +55,10 @@ class ReadOnlyModeInterceptor( 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/authorization/FeatureAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/FeatureAuthorizationInterceptor.kt index e77c64c5a2..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 @@ -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) } } From 24a97a08d33ec222ce4658c12fb4a99d509b73d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Oct 2025 14:13:47 +0200 Subject: [PATCH 12/29] fix: move all read only mode checks to read only interceptor and ignore permissions requierd by endpoints - more idiomatic behavior and easier to understand --- .../tolgee/configuration/WebSecurityConfig.kt | 7 +-- .../AdministrationControllerTest.kt | 51 +++++++++++++++++++ .../data/AdministrationTestData.kt | 8 +++ .../AbstractAuthorizationInterceptor.kt | 2 +- .../OrganizationAuthorizationInterceptor.kt | 33 ++++-------- .../ProjectAuthorizationInterceptor.kt | 32 +++++------- .../translationTools/useErrorTranslation.ts | 2 + 7 files changed, 86 insertions(+), 49 deletions(-) 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 4ff9abba37..c656fc7370 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -140,12 +140,7 @@ class WebSecurityConfig( registry.addInterceptor(adminAccessInterceptor) .addPathPatterns(*ADMIN_ENDPOINTS) registry.addInterceptor(readOnlyModeInterceptor) - .excludePathPatterns( - *PUBLIC_ENDPOINTS, - *INTERNAL_ENDPOINTS, - *PROJECT_ENDPOINTS, - *ORGANIZATION_ENDPOINTS - ) + .excludePathPatterns(*PUBLIC_ENDPOINTS, *INTERNAL_ENDPOINTS) registry.addInterceptor(featureAuthorizationInterceptor) } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AdministrationControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AdministrationControllerTest.kt index fbec13ee5c..f180dc34af 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AdministrationControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/AdministrationControllerTest.kt @@ -1,8 +1,10 @@ package io.tolgee.api.v2.controllers 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/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/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt index 0d9de78d1e..20e8f7b862 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 @@ -60,7 +60,7 @@ abstract class AbstractAuthorizationInterceptor( 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/OrganizationAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt index 59c0995e52..808120603a 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,7 +16,6 @@ package io.tolgee.security.authorization -import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.hasAdminAccess import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException @@ -64,12 +63,6 @@ class OrganizationAuthorizationInterceptor( var bypassed = false val requiredRole = getRequiredRole(request, handler) - val isReadOnly = isReadOnlyMethod( - request, - handler, - usesWritePermissions = requiredRole?.isReadOnly == false - ) - val canBypass = user.hasAdminAccess(isReadonlyAccess = isReadOnly) logger.debug( "Checking access to org#{} by user#{} (Requires {})", organization.id, @@ -78,15 +71,14 @@ class OrganizationAuthorizationInterceptor( ) if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { - if (!canBypass) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to org#{} for user#{} - No view permissions", organization.id, userId, ) - val canBypassReadOnly = user.hasAdminAccess(isReadonlyAccess = true) - if (!canBypassReadOnly) { + if (!canBypass(request, handler, isReadOnly = true)) { // Security consideration: if the user cannot see the organization, pretend it does not exist. throw NotFoundException() } @@ -99,7 +91,7 @@ class OrganizationAuthorizationInterceptor( } if (requiredRole != null && !organizationRoleService.isUserOfRole(userId, organization.id, requiredRole)) { - if (!canBypass) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to org#{} for user#{} - Insufficient role", organization.id, @@ -112,17 +104,6 @@ class OrganizationAuthorizationInterceptor( bypassed = true } - if (authenticationFacade.isReadOnly && !isReadOnly) { - // This check can't be bypassed - logger.debug( - "Rejecting access to org#{} for user#{} - Write operation is not allowed in read-only mode", - organization.id, - userId, - ) - - throw PermissionException(Message.OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE) - } - if (bypassed) { logger.info( "Use of admin privileges: user#{} failed local security checks for org#{} - bypassing for {} {}", @@ -158,4 +139,12 @@ class OrganizationAuthorizationInterceptor( return orgPermission?.role } + + private fun canBypass( + request: HttpServletRequest, + handler: HandlerMethod, + isReadOnly: Boolean = isReadOnlyMethod(request, handler) + ): Boolean { + return authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadOnly) + } } 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 757b416c3d..b9902c02af 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 @@ -67,10 +67,6 @@ class ProjectAuthorizationInterceptor( var bypassed = false val requiredScopes = getRequiredScopes(request, handler) - val isReadOnly = isReadOnlyMethod(request, handler, usesWritePermissions = !Scope.areAllReadOnly(requiredScopes)) - val hasAdminAccess = user.hasAdminAccess(isReadonlyAccess = isReadOnly) - val canUseAdminRights = !authenticationFacade.isProjectApiKeyAuth - val canBypass = hasAdminAccess && canUseAdminRights val formattedRequirements = requiredScopes?.joinToString(", ") { it.value } ?: "read-only" logger.debug("Checking access to proj#${project.id} by user#$userId (Requires $formattedRequirements)") @@ -78,16 +74,14 @@ class ProjectAuthorizationInterceptor( val scopes = securityService.getCurrentPermittedScopes(project.id) if (scopes.isEmpty()) { - if (!canBypass) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to proj#{} for user#{} - No view permissions", project.id, userId, ) - val hasReadOnlyAdminAccess = user.hasAdminAccess(isReadonlyAccess = true) - val canBypassReadOnly = hasReadOnlyAdminAccess && canUseAdminRights - if (!canBypassReadOnly) { + if (!canBypass(request, handler, isReadOnly = true)) { // Security consideration: if the user cannot see the project, pretend it does not exist. throw ProjectNotFoundException(project.id) } @@ -102,7 +96,7 @@ class ProjectAuthorizationInterceptor( val missingScopes = getMissingScopes(requiredScopes, scopes) if (missingScopes.isNotEmpty()) { - if (!canBypass) { + if (!canBypass(request, handler)) { logger.debug( "Rejecting access to proj#{} for user#{} - Insufficient permissions", project.id, @@ -133,17 +127,6 @@ class ProjectAuthorizationInterceptor( } } - if (authenticationFacade.isReadOnly && !isReadOnly) { - // This check can't be bypassed - logger.debug( - "Rejecting access to proj#{} for user#{} - Write operation is not allowed in read-only mode", - project.id, - userId, - ) - - throw PermissionException(Message.OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE) - } - if (bypassed) { logger.info( "Use of admin privileges: user#{} failed local security checks for proj#{} - bypassing for {} {}", @@ -199,4 +182,13 @@ class ProjectAuthorizationInterceptor( return projectPerms?.scopes } + + private val canUseAdminRights + get() = !authenticationFacade.isProjectApiKeyAuth + + private fun canBypass(request: HttpServletRequest, handler: HandlerMethod, isReadOnly: Boolean? = null): Boolean { + val isReadonlyAccess = isReadOnly ?: isReadOnlyMethod(request, handler) + val hasAdminAccess = authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadonlyAccess) + return hasAdminAccess && canUseAdminRights + } } 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; } From 8afd570e95bcfbe5bfeb0e5abaabd5fea205f4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Oct 2025 15:07:11 +0200 Subject: [PATCH 13/29] fix: document if operation is read only or not in openapi --- .../openApi/OpenApiConfiguration.kt | 6 +-- .../openApi/OpenApiGroupBuilder.kt | 23 ++++++++--- .../openApi/OpenApiSecurityHelper.kt | 2 +- .../authentication/AdminAccessInterceptor.kt | 2 +- .../authentication/ReadOnlyModeExtension.kt | 32 +++++++++++++++ .../authentication/ReadOnlyModeInterceptor.kt | 2 +- .../AbstractAuthorizationInterceptor.kt | 39 ------------------- .../OrganizationAuthorizationInterceptor.kt | 3 +- .../ProjectAuthorizationInterceptor.kt | 10 +++-- 9 files changed, 65 insertions(+), 54 deletions(-) create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeExtension.kt 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/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt index 4d10b86379..17f6f3b16b 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt @@ -51,7 +51,7 @@ class AdminAccessInterceptor( } val hasReadAccess = authenticationFacade.authenticatedUser.isSupporterOrAdmin() - if (hasReadAccess && isReadOnlyMethod(request, handler)) { + if (hasReadAccess && handler.isReadOnly(request.method)) { // These methods should be read-only - safe to call return true } 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 index eb244a86f7..36ad5e7e67 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -50,7 +50,7 @@ class ReadOnlyModeInterceptor( return true } - if (isReadOnlyMethod(request, handler)) { + if (handler.isReadOnly(request.method)) { // These methods should be read-only - safe to call from read-only mode return true } 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 20e8f7b862..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 @@ -16,8 +16,6 @@ package io.tolgee.security.authorization -import io.tolgee.security.authentication.ReadOnlyOperation -import io.tolgee.security.authentication.WriteOperation import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -64,41 +62,4 @@ abstract class AbstractAuthorizationInterceptor( val annotation = AnnotationUtils.getAnnotation(handler.method, IsGlobalRoute::class.java) return annotation != null } - - /** - * Determines if the target endpoint is read-only. Can be overridden by annotating the method with - * [ReadOnlyOperation] or [WriteOperation] annotation. - * - * @param usesWritePermissions whether the request uses write permissions; if false, the method is - * considered read-only; ignored if null - */ - fun isReadOnlyMethod( - request: HttpServletRequest, - handler: HandlerMethod, - usesWritePermissions: Boolean? = null - ): Boolean { - val forceReadOnly = AnnotationUtils.getAnnotation(handler.method, ReadOnlyOperation::class.java) != null - val forceWrite = AnnotationUtils.getAnnotation(handler.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 request.method in READ_ONLY_METHODS || usesWritePermissions == false - } - - companion object { - val READ_ONLY_METHODS = arrayOf("GET", "HEAD", "OPTIONS") - } } 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 808120603a..38c1828a37 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 @@ -23,6 +23,7 @@ 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 @@ -143,7 +144,7 @@ class OrganizationAuthorizationInterceptor( private fun canBypass( request: HttpServletRequest, handler: HandlerMethod, - isReadOnly: Boolean = isReadOnlyMethod(request, handler) + isReadOnly: Boolean = handler.isReadOnly(request.method) ): Boolean { return authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadOnly) } 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 b9902c02af..237993f1bc 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 @@ -27,6 +27,7 @@ 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 @@ -186,9 +187,12 @@ class ProjectAuthorizationInterceptor( private val canUseAdminRights get() = !authenticationFacade.isProjectApiKeyAuth - private fun canBypass(request: HttpServletRequest, handler: HandlerMethod, isReadOnly: Boolean? = null): Boolean { - val isReadonlyAccess = isReadOnly ?: isReadOnlyMethod(request, handler) - val hasAdminAccess = authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadonlyAccess) + private fun canBypass( + request: HttpServletRequest, + handler: HandlerMethod, + isReadOnly: Boolean = handler.isReadOnly(request.method), + ): Boolean { + val hasAdminAccess = authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadOnly) return hasAdminAccess && canUseAdminRights } } From 62e528437ac8568da803805f2235b29b0e19f067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Oct 2025 16:48:36 +0200 Subject: [PATCH 14/29] fix: setup proper read only rules for existing controllers --- .../tolgee/api/v2/controllers/SlugController.kt | 3 +++ .../api/v2/controllers/V2InvitationController.kt | 2 ++ .../v2/controllers/batch/V2ExportController.kt | 2 ++ .../api/v2/controllers/keys/KeyController.kt | 2 ++ .../TranslationSuggestionController.kt | 2 ++ .../component/PreferredOrganizationFacade.kt | 16 ++++++++++++++-- .../AiPromptCustomizationController.kt | 2 +- .../ee/api/v2/controllers/PromptController.kt | 1 + .../api/v2/controllers/SuggestionController.kt | 1 + .../ee/api/v2/controllers/TaskController.kt | 2 ++ .../controllers/glossary/GlossaryController.kt | 2 ++ .../glossary/GlossaryTermHighlightsController.kt | 2 ++ 12 files changed, 34 insertions(+), 3 deletions(-) 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/V2InvitationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2InvitationController.kt index 892087b217..e44943dd73 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,6 +54,7 @@ class V2InvitationController( private val publicInvitationModelAssembler: PublicInvitationModelAssembler, ) { @GetMapping("/v2/invitations/{code}/accept") + @WriteOperation @Operation(summary = "Accepts invitation to project or organization") fun acceptInvitation( @PathVariable("code") code: String?, 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/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/component/PreferredOrganizationFacade.kt b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt index 858ed9e411..ff30da4cc6 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt @@ -3,6 +3,7 @@ package io.tolgee.component import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider import io.tolgee.hateoas.organization.PrivateOrganizationModel import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler +import io.tolgee.model.UserPreferences import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.UserPreferencesService @@ -18,8 +19,7 @@ class PreferredOrganizationFacade( private val organizationService: OrganizationService, ) { fun getPreferred(): PrivateOrganizationModel? { - val preferences = userPreferencesService.findOrCreate(authenticationFacade.authenticatedUser.id) - val preferredOrganization = preferences.preferredOrganization + val preferredOrganization = getCurrentUserPreferences()?.preferredOrganization if (preferredOrganization != null) { val view = organizationService.findPrivateView(preferredOrganization.id, authenticationFacade.authenticatedUser.id) @@ -31,4 +31,16 @@ class PreferredOrganizationFacade( } return null } + + private fun getCurrentUserPreferences(): UserPreferences? { + val userId = authenticationFacade.authenticatedUser.id + + val inReadOnlyMode = authenticationFacade.isReadOnly + if (inReadOnlyMode) { + // Avoid modifying operations in read-only mode + return userPreferencesService.find(userId) + } + + return userPreferencesService.findOrCreate(userId) + } } 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/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) From 4cbb9bc04a21bccb5714dcf373e2e911c24daff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 13:28:31 +0200 Subject: [PATCH 15/29] fix: better handling of extended permissions for supporter --- .../io/tolgee/dtos/ComputedPermissionDto.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 edc1a3c2b0..75f72c82cf 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt @@ -69,8 +69,11 @@ class ComputedPermissionDto( if (userRole == UserAccount.Role.ADMIN && !this.isAllPermitted) { return SERVER_ADMIN } - if (userRole == UserAccount.Role.SUPPORTER) { - if (this === NONE) { + 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( @@ -120,11 +123,15 @@ class ComputedPermissionDto( private fun getExtendedPermission(base: IPermission, extendedScopes: Array): IPermission { return object : IPermission by base { - override val scopes: Array - get() = base.scopes + extendedScopes + override val scopes: Array by lazy { + (base.scopes + extendedScopes).toSet().toTypedArray() + } } } + val ComputedPermissionDto.isAllReadOnlyPermitted: Boolean + get() = expandedScopes.toSet().containsAll(Scope.entries.filter { it.isReadOnly() }) + val NONE get() = ComputedPermissionDto(getEmptyPermission(scopes = arrayOf(), ProjectPermissionType.NONE)) val ORGANIZATION_OWNER From d17a67eecf0ab99d3fbea3f0d37afc840b965070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 13:33:31 +0200 Subject: [PATCH 16/29] chore: document `emitTokenRefreshForCurrentUser` behavior --- .../io/tolgee/security/authentication/JwtService.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 ae285f4cea..335e943f04 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 @@ -97,6 +97,14 @@ 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 { From 33ddbeceac19015f303f8142fbe3f1474b211761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 13:44:12 +0200 Subject: [PATCH 17/29] fix: make sure actor can impersonate when validating token; doc improvements --- .../io/tolgee/security/authentication/JwtService.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 335e943f04..072ae612c7 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 @@ -29,6 +29,7 @@ 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.service.security.UserAccountService @@ -181,10 +182,18 @@ class JwtService( val roClaim = jws.body[JWT_TOKEN_READ_ONLY_CLAIM] as? Boolean ?: false if (roClaim && account.isAdmin()) { - // we don't allow read-only admin impersonation to make our lives easier + // 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( credentials = jws, deviceId = deviceId, From e12649ead93a8251343571d10c712a91275f3be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 13:46:32 +0200 Subject: [PATCH 18/29] fix: store actor id explicitly as string --- .../main/kotlin/io/tolgee/security/authentication/JwtService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 072ae612c7..1ada77f60a 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 @@ -80,7 +80,7 @@ class JwtService( .setExpiration(expiration) if (actingAsUserAccountId != null) { - builder.claim(JWT_TOKEN_ACTING_USER_ID_CLAIM, actingAsUserAccountId) + builder.claim(JWT_TOKEN_ACTING_USER_ID_CLAIM, actingAsUserAccountId.toString()) } val deviceId = UUID.randomUUID().toString() From 15bf3f801c8ef042c4d377e16dfd298546a7327e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 14:03:14 +0200 Subject: [PATCH 19/29] chore: update JwtServiceTest --- .../security/authentication/JwtServiceTest.kt | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) 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 d67d0f297f..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) @@ -53,6 +57,8 @@ class JwtServiceTest { 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( @@ -74,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 @@ -216,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() + } } From 195c77287142c93e9a27368408f6e7ccafac7362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 14:21:41 +0200 Subject: [PATCH 20/29] fix: use precalculated list of read only scopes --- .../src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt | 2 +- backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 75f72c82cf..ce1f870bc5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt @@ -130,7 +130,7 @@ class ComputedPermissionDto( } val ComputedPermissionDto.isAllReadOnlyPermitted: Boolean - get() = expandedScopes.toSet().containsAll(Scope.entries.filter { it.isReadOnly() }) + get() = expandedScopes.toSet().containsAll(Scope.readOnlyScopes.toList()) val NONE get() = ComputedPermissionDto(getEmptyPermission(scopes = arrayOf(), ProjectPermissionType.NONE)) 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 e7dd0f4f7f..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 @@ -52,7 +52,7 @@ enum class Scope( companion object { - private val readOnlyScopes by lazy { ALL_VIEW.expand() } + val readOnlyScopes by lazy { ALL_VIEW.expand() } private val keysView = HierarchyItem(KEYS_VIEW) private val translationsView = HierarchyItem(TRANSLATIONS_VIEW, listOf(keysView)) From ed090835b5204f794ddf7214334f513db3333bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 14:23:34 +0200 Subject: [PATCH 21/29] chore: fix interceptor docstrings --- .../tolgee/security/authentication/AdminAccessInterceptor.kt | 5 +++-- .../security/authentication/ReadOnlyModeInterceptor.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 index 17f6f3b16b..c61eb70b4f 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AdminAccessInterceptor.kt @@ -27,8 +27,9 @@ 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] to override. + * 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( 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 index 36ad5e7e67..a0be3c8507 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptor.kt @@ -27,7 +27,7 @@ import org.springframework.web.method.HandlerMethod /** * Blocks write requests when the current authentication is read-only. - * Annotate class or method with [ReadOnlyOperation] to override. + * Annotate class or method with [ReadOnlyOperation] or [WriteOperation] to override. */ @Component class ReadOnlyModeInterceptor( From 523b1ebdeb7d0cbd20457f1c9d84c6d94110c7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 14:28:17 +0200 Subject: [PATCH 22/29] fix: update schema --- webapp/src/service/apiSchema.generated.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 9525ce7cf5..47dd58ff06 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -2450,7 +2450,8 @@ export interface components { | "duplicate_suggestion" | "unsupported_media_type" | "impersonation_of_admin_by_supporter_not_allowed" - | "already_impersonating_user"; + | "already_impersonating_user" + | "operation_not_permitted_in_read_only_mode"; params?: unknown[]; }; ExistenceEntityDescription: { @@ -5528,7 +5529,8 @@ export interface components { | "duplicate_suggestion" | "unsupported_media_type" | "impersonation_of_admin_by_supporter_not_allowed" - | "already_impersonating_user"; + | "already_impersonating_user" + | "operation_not_permitted_in_read_only_mode"; params?: unknown[]; success: boolean; }; From 67c782cdb2d594b0de063747f3b843862c565bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 14:35:46 +0200 Subject: [PATCH 23/29] chore: fix missing field in read only interceptor test --- .../security/authentication/ReadOnlyModeInterceptorTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index 561155d9bc..d807cfff04 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/ReadOnlyModeInterceptorTest.kt @@ -16,6 +16,7 @@ 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 @@ -51,6 +52,7 @@ class ReadOnlyModeInterceptorTest { @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() From 29ecf07e7b019abacd63570c54348cda1b52eda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 3 Oct 2025 14:47:59 +0200 Subject: [PATCH 24/29] chore: fix project and organization interceptors to follow updated interceptor logic --- ...rganizationAuthorizationInterceptorTest.kt | 24 ++----------------- .../ProjectAuthorizationInterceptorTest.kt | 23 ++---------------- 2 files changed, 4 insertions(+), 43 deletions(-) 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 f6d25d0810..d5f23cf85c 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 @@ -161,24 +161,6 @@ class OrganizationAuthorizationInterceptorTest { performWriteRequests { all -> all.andIsForbidden } } - @Test - fun `it allows user with read-only token to access read-only organization endpoints`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(true) - Mockito.`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)).thenReturn(true) - Mockito.`when`(authentication.isReadOnly).thenReturn(true) - - performReadOnlyRequests { all -> all.andIsOk } - } - - @Test - fun `it does not let user with read-only token to access write organization endpoints`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(true) - Mockito.`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)).thenReturn(true) - Mockito.`when`(authentication.isReadOnly).thenReturn(true) - - performWriteRequests { all -> all.andIsForbidden } - } - @Test fun `it allows admin to access any endpoint`() { Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) @@ -193,9 +175,6 @@ class OrganizationAuthorizationInterceptorTest { 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 permissions - mockMvc.perform(post("/v2/organizations/1337/default-perms-write-method")).andSatisfies(condition) - // POST method, but with read-only annotation mockMvc.perform(post("/v2/organizations/1337/requires-owner-read-annotation")).andSatisfies(condition) } @@ -205,7 +184,8 @@ class OrganizationAuthorizationInterceptorTest { 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 and write permissions + // 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) } 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 34f04c7e6a..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 @@ -272,23 +272,6 @@ class ProjectAuthorizationInterceptorTest { performWriteRequests { all -> all.andIsForbidden } } - @Test - fun `it allows user with read-only token to access read-only project endpoints`() { - Mockito.`when`(authentication.isReadOnly).thenReturn(true) - - Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(setOf(Scope.KEYS_CREATE)) - - performReadOnlyRequests { all -> all.andIsOk } - } - - @Test - fun `it does not let user with read-only token to access write project endpoints`() { - Mockito.`when`(authentication.isReadOnly).thenReturn(true) - Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(setOf(Scope.KEYS_CREATE)) - - performWriteRequests { all -> all.andIsForbidden } - } - @Test fun `it allows admin to access any endpoint`() { Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.ADMIN) @@ -303,9 +286,6 @@ class ProjectAuthorizationInterceptorTest { 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 permissions - mockMvc.perform(MockMvcRequestBuilders.post("/v2/projects/1337/default-perms-write-method")).andSatisfies(condition) - // POST method, but with read-only annotation mockMvc.perform( MockMvcRequestBuilders.post("/v2/projects/1337/requires-single-scope-read-annotation") @@ -321,7 +301,8 @@ class ProjectAuthorizationInterceptorTest { MockMvcRequestBuilders.get("/v2/projects/1337/requires-single-scope-write-annotation") ).andSatisfies(condition) - // POST method and write permissions + // 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) From d651140dc7e0546342846c82d430932e5433bbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 8 Oct 2025 15:03:22 +0200 Subject: [PATCH 25/29] fix: code readability improvements --- .../v2/controllers/V2InvitationController.kt | 3 +- .../controllers/project/ProjectsController.kt | 2 +- .../project/ProjectsTransferringController.kt | 2 +- .../tolgee/dtos/cacheable/UserAccountDto.kt | 4 - .../kotlin/io/tolgee/model/UserAccount.kt | 14 +-- .../organization/OrganizationRoleService.kt | 54 +++++------- .../service/security/SecurityService.kt | 86 ++++++++++--------- .../OrganizationAuthorizationInterceptor.kt | 20 +++-- .../ProjectAuthorizationInterceptor.kt | 23 +++-- 9 files changed, 100 insertions(+), 108 deletions(-) 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 e44943dd73..8e1fd92b42 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 @@ -80,9 +80,8 @@ class V2InvitationController( } invitation.organizationRole?.let { - organizationRoleService.checkUserIsOwner( + organizationRoleService.checkUserCanDeleteInvitation( invitation.organizationRole!!.organization!!.id, - isReadOnlyAccess = false, ) } 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 7c69609aeb..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, isReadOnlyAccess = false) + organizationRoleService.checkUserCanCreateProject(dto.organizationId) val project = projectCreationService.createProject(dto) if (organizationRoleService.getType(dto.organizationId) == OrganizationRoleType.MAINTAINER) { // Maintainers get full access to projects they create 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 7b7f847acc..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, isReadOnlyAccess = false) + organizationRoleService.checkUserCanTransferProjectToOrganization(organizationId) projectService.transferToOrganization(projectId, organizationId) } } 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 38cad7708c..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 @@ -55,7 +55,3 @@ fun UserAccountDto.isSupporter(): Boolean { fun UserAccountDto.isSupporterOrAdmin(): Boolean { return role == UserAccount.Role.SUPPORTER || role == UserAccount.Role.ADMIN } - -fun UserAccountDto.hasAdminAccess(isReadonlyAccess: Boolean): Boolean { - return role?.hasAdminAccess(isReadonlyAccess) ?: false -} 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 3c7de0c241..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,15 +164,7 @@ data class UserAccount( enum class Role { USER, ADMIN, - SUPPORTER; - - fun hasAdminAccess(isReadonlyAccess: Boolean): Boolean { - return when (this) { - ADMIN -> true - SUPPORTER -> isReadonlyAccess - else -> false - } - } + SUPPORTER, } enum class AccountType { @@ -196,7 +188,3 @@ fun UserAccount.isSupporter(): Boolean { fun UserAccount.isSupporterOrAdmin(): Boolean { return role == UserAccount.Role.SUPPORTER || role == UserAccount.Role.ADMIN } - -fun UserAccount.hasAdminAccess(isReadonlyAccess: Boolean): Boolean { - return role?.hasAdminAccess(isReadonlyAccess) ?: 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 52573ad354..d46411bff8 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,7 +4,7 @@ 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.hasAdminAccess +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 @@ -113,60 +113,46 @@ class OrganizationRoleService( } } - fun checkUserIsOwner( - userId: Long, - organizationId: Long, - isReadOnlyAccess: Boolean, - ) { - if (this.isUserOwner(userId, organizationId)) { + fun checkUserIsOwnerOrServerAdmin(organizationId: Long) { + val user = authenticationFacade.authenticatedUser + if (this.isUserOwner(user.id, organizationId)) { return } - if (userAccountService.getDto(userId).hasAdminAccess(isReadOnlyAccess)) { + if (user.isAdmin()) { return } throw PermissionException(Message.USER_IS_NOT_OWNER_OF_ORGANIZATION) } - fun checkUserIsOwnerOrMaintainer( - userId: Long, + fun checkUserCanDeleteInvitation( organizationId: Long, - isReadOnlyAccess: Boolean, ) { - if (this.isUserOwnerOrMaintainer(userId, organizationId)) { - return - } - - if (userAccountService.getDto(userId).hasAdminAccess(isReadOnlyAccess)) { - return - } - - throw PermissionException(Message.USER_IS_NOT_OWNER_OR_MAINTAINER_OF_ORGANIZATION) + checkUserIsOwnerOrServerAdmin(organizationId) } - fun checkUserIsOwner(organizationId: Long, isReadOnlyAccess: Boolean) { - this.checkUserIsOwner(authenticationFacade.authenticatedUser.id, organizationId, isReadOnlyAccess) - } - - fun checkUserIsOwnerOrMaintainer(organizationId: Long, isReadOnlyAccess: Boolean) { - this.checkUserIsOwnerOrMaintainer(authenticationFacade.authenticatedUser.id, organizationId, isReadOnlyAccess) - } - - fun checkUserIsMember( - userId: Long, + fun checkUserCanTransferProjectToOrganization( organizationId: Long, - isReadOnlyAccess: Boolean, ) { - if (hasAnyOrganizationRole(userId, organizationId)) { + checkUserIsOwnerOrServerAdmin(organizationId) + } + + fun checkUserIsOwnerOrMaintainerOrServerAdmin(organizationId: Long) { + val user = authenticationFacade.authenticatedUser + if (this.isUserOwnerOrMaintainer(user.id, organizationId)) { return } - if (userAccountService.getDto(userId).hasAdminAccess(isReadOnlyAccess)) { + if (user.isAdmin()) { return } - throw PermissionException(Message.USER_IS_NOT_MEMBER_OF_ORGANIZATION) + 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/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index a33a2105ff..add4f141c9 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,7 +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.hasAdminAccess +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 @@ -49,10 +50,10 @@ class SecurityService( @Autowired private lateinit var labelService: LabelService - fun checkAnyProjectPermission(projectId: Long, isReadonlyAccess: Boolean) { + fun checkAnyProjectPermission(projectId: Long) { if ( getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() && - !hasCurrentUserServerAdminAccess(isReadonlyAccess) + !activeUser.isSupporterOrAdmin() ) { throw PermissionException(Message.USER_HAS_NO_PROJECT_ACCESS) } @@ -155,7 +156,12 @@ class SecurityService( requiredScope: Scope, userAccountDto: UserAccountDto, ) { - if (userAccountDto.hasAdminAccess(isReadonlyAccess = requiredScope.isReadOnly())) { + if (userAccountDto.isAdmin()) { + return + } + + val isReadonlyAccess = requiredScope.isReadOnly() + if (isReadonlyAccess && userAccountDto.isSupporterOrAdmin()) { return } @@ -200,26 +206,16 @@ class SecurityService( ) { 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()) } - } - fun checkLanguageSuggestPermission( projectId: Long, languageIds: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_SUGGEST) - checkLanguagePermission( - projectId, - isReadonlyAccess = false, - ) { data -> data.checkSuggestPermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkSuggestPermitted(*languageIds.toLongArray()) } + } } fun checkLanguageViewPermission( @@ -227,10 +223,11 @@ class SecurityService( languageIds: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) - checkLanguagePermission( - projectId, - isReadonlyAccess = true, - ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + runIfUserNotServerSupporterOrAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + } } private fun translationsInTask( @@ -240,10 +237,11 @@ class SecurityService( keyId: Long? = null, ): Boolean { checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) - checkLanguagePermission( - projectId, - isReadonlyAccess = true, - ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + runIfUserNotServerSupporterOrAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } + } if (keyId != null && languageIds.isNotEmpty()) { languageIds.forEach { @@ -265,10 +263,11 @@ class SecurityService( passIfAnyPermissionCheckSucceeds( { checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) - checkLanguagePermission( - projectId, - isReadonlyAccess = false, - ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + } }, { if (!translationsInTask(projectId, TaskType.TRANSLATE, languageIds, keyId)) { @@ -297,10 +296,11 @@ class SecurityService( ) { try { checkProjectPermission(projectId, Scope.TRANSLATIONS_STATE_EDIT) - checkLanguagePermission( - projectId, - isReadonlyAccess = false, - ) { 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 @@ -338,12 +338,8 @@ class SecurityService( private fun checkLanguagePermission( projectId: Long, - isReadonlyAccess: Boolean, permissionCheckFn: (data: ComputedPermissionDto) -> Unit, ) { - if (hasCurrentUserServerAdminAccess(isReadonlyAccess)) { - return - } val usersPermission = permissionService.getProjectPermissionData( projectId, @@ -551,8 +547,16 @@ class SecurityService( } } - private fun hasCurrentUserServerAdminAccess(isReadonlyAccess: Boolean): Boolean { - return activeUser.hasAdminAccess(isReadonlyAccess) + private fun runIfUserNotServerAdmin(runnable: () -> Unit) { + if (!activeUser.isAdmin()) { + runnable() + } + } + + private fun runIfUserNotServerSupporterOrAdmin(runnable: () -> Unit) { + if (!activeUser.isSupporterOrAdmin()) { + runnable() + } } private val activeUser: UserAccountDto 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 38c1828a37..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,7 +16,8 @@ package io.tolgee.security.authorization -import io.tolgee.dtos.cacheable.hasAdminAccess +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.enums.OrganizationRoleType @@ -53,8 +54,7 @@ class OrganizationAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { - val user = authenticationFacade.authenticatedUser - val userId = user.id + val userId = authenticationFacade.authenticatedUser.id val organization = requestContextService.getTargetOrganization(request) // Two possible scenarios: we're on `GET/POST /v2/organization`, or the organization was not found. @@ -79,7 +79,7 @@ class OrganizationAuthorizationInterceptor( userId, ) - if (!canBypass(request, handler, isReadOnly = true)) { + if (!canBypassForReadOnly()) { // Security consideration: if the user cannot see the organization, pretend it does not exist. throw NotFoundException() } @@ -144,8 +144,16 @@ class OrganizationAuthorizationInterceptor( private fun canBypass( request: HttpServletRequest, handler: HandlerMethod, - isReadOnly: Boolean = handler.isReadOnly(request.method) ): Boolean { - return authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadOnly) + 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 237993f1bc..fcec38e211 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,7 +18,8 @@ package io.tolgee.security.authorization import io.tolgee.activity.ActivityHolder import io.tolgee.constants.Message -import io.tolgee.dtos.cacheable.hasAdminAccess +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 @@ -57,7 +58,6 @@ class ProjectAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { - val user = authenticationFacade.authenticatedUser val userId = authenticationFacade.authenticatedUser.id val project = requestContextService.getTargetProject(request) @@ -82,7 +82,7 @@ class ProjectAuthorizationInterceptor( userId, ) - if (!canBypass(request, handler, isReadOnly = true)) { + if (!canBypassForReadOnly()) { // Security consideration: if the user cannot see the project, pretend it does not exist. throw ProjectNotFoundException(project.id) } @@ -190,9 +190,20 @@ class ProjectAuthorizationInterceptor( private fun canBypass( request: HttpServletRequest, handler: HandlerMethod, - isReadOnly: Boolean = handler.isReadOnly(request.method), ): Boolean { - val hasAdminAccess = authenticationFacade.authenticatedUser.hasAdminAccess(isReadonlyAccess = isReadOnly) - return hasAdminAccess && canUseAdminRights + if (!canUseAdminRights) { + return false + } + + if (authenticationFacade.authenticatedUser.isAdmin()) { + return true + } + + val forReadOnly = handler.isReadOnly(request.method) + return forReadOnly && canBypassForReadOnly() + } + + private fun canBypassForReadOnly(): Boolean { + return canUseAdminRights && authenticationFacade.authenticatedUser.isSupporterOrAdmin() } } From 9ba48c2779e9699093f2a9a5611c9533050ea670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 8 Oct 2025 15:13:05 +0200 Subject: [PATCH 26/29] fix: code readability improvements --- .../controllers/AdministrationController.kt | 10 ++---- .../security/authentication/JwtService.kt | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt index 63f29ed9f1..9f2788e7c8 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt @@ -157,16 +157,10 @@ class AdministrationController( val actingUser = authenticationFacade.authenticatedUser val user = userAccountService.get(userId) - val isAdmin = actingUser.isAdmin() - if (user.isAdmin() > actingUser.isAdmin()) { + 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.emitToken( - userAccountId = user.id, - actingAsUserAccountId = actingUser.id, - isReadOnly = !isAdmin, - isSuper = isAdmin - ) + return jwtService.emitImpersonationToken(user.id) } } 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 1ada77f60a..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 @@ -32,6 +32,7 @@ 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 @@ -117,6 +118,36 @@ class JwtService( ) } + 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. * From c58cac8263d212b53312bb3d0983495236d7b385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 8 Oct 2025 15:16:38 +0200 Subject: [PATCH 27/29] fix: build --- .../io/tolgee/api/v2/controllers/BusinessEventController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt index 8973bf672e..792e9d5eb1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt @@ -34,7 +34,7 @@ class BusinessEventController( @RequestBody eventData: BusinessEventReportRequest, ) { try { - eventData.projectId?.let { securityService.checkAnyProjectPermission(it, isReadonlyAccess = true) } + eventData.projectId?.let { securityService.checkAnyProjectPermission(it) } eventData.organizationId?.let { organizationRoleService.checkUserCanView(it) } businessEventPublisher.publish(eventData) } catch (e: Throwable) { From 1f3db23d9d0d8d8fa42d82d2b891a3d7433dd774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 8 Oct 2025 15:33:54 +0200 Subject: [PATCH 28/29] fix: allow bypassing `checkLanguagePermissionByTag` and merge view permissions check for `translationsInTask` --- .../service/security/SecurityService.kt | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) 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 add4f141c9..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 @@ -189,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( @@ -200,10 +202,12 @@ class SecurityService( languageTags: Collection, ) { checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) - checkLanguagePermissionByTag( - projectId, - languageTags, - ) { data, languageIds -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + runIfUserNotServerAdmin { + checkLanguagePermissionByTag( + projectId, + languageTags, + ) { data, languageIds -> data.checkTranslatePermitted(*languageIds.toLongArray()) } + } } fun checkLanguageSuggestPermission( @@ -236,12 +240,7 @@ class SecurityService( languageIds: Collection, keyId: Long? = null, ): Boolean { - checkProjectPermission(projectId, Scope.TRANSLATIONS_VIEW) - runIfUserNotServerSupporterOrAdmin { - checkLanguagePermission( - projectId, - ) { data -> data.checkViewPermitted(*languageIds.toLongArray()) } - } + checkLanguageViewPermission(projectId, languageIds) if (keyId != null && languageIds.isNotEmpty()) { languageIds.forEach { From 2eb7aa3743d1150590efce66fc293f3c8f37c078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 8 Oct 2025 15:38:00 +0200 Subject: [PATCH 29/29] fix: use UserAccountDto instead of full entity --- .../io/tolgee/api/v2/controllers/AdministrationController.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt index 9f2788e7c8..fe39e1d593 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/AdministrationController.kt @@ -11,7 +11,6 @@ import io.tolgee.hateoas.organization.OrganizationModelAssembler import io.tolgee.hateoas.userAccount.UserAccountModel import io.tolgee.hateoas.userAccount.UserAccountModelAssembler import io.tolgee.model.UserAccount -import io.tolgee.model.isAdmin import io.tolgee.openApiDocs.OpenApiSelfHostedExtension import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.JwtService @@ -156,7 +155,7 @@ class AdministrationController( } val actingUser = authenticationFacade.authenticatedUser - val user = userAccountService.get(userId) + 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)