Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
add5452
feat: supporter user role
Anty0 Sep 18, 2025
15c5f85
feat: implemented read only tokens and impersonation actor support
Anty0 Sep 18, 2025
65ed5a6
feat: handle read only distinction in interceptors
Anty0 Sep 23, 2025
2e55e3d
chore: fix lint
Anty0 Sep 23, 2025
08695b5
feat: implement supporter role on FE side + fixes;
Anty0 Sep 29, 2025
5c16726
fix: add missing permission to the hierarchy
Anty0 Sep 30, 2025
fed70dd
fix: tests for new interceptors
Anty0 Oct 1, 2025
737810f
fix: make sure override annotations won't be ignored
Anty0 Oct 1, 2025
0a748d3
fix: updated tests for existing interceptors + fixes and readability …
Anty0 Oct 1, 2025
f82763c
fix: separate message for read only denied requests + don't filter pr…
Anty0 Oct 2, 2025
7bf1562
fix: log rejected requests by read only interceptor + better feature …
Anty0 Oct 2, 2025
24a97a0
fix: move all read only mode checks to read only interceptor and igno…
Anty0 Oct 2, 2025
8afd570
fix: document if operation is read only or not in openapi
Anty0 Oct 2, 2025
62e5284
fix: setup proper read only rules for existing controllers
Anty0 Oct 2, 2025
4cbb9bc
fix: better handling of extended permissions for supporter
Anty0 Oct 3, 2025
d17a67e
chore: document `emitTokenRefreshForCurrentUser` behavior
Anty0 Oct 3, 2025
33ddbec
fix: make sure actor can impersonate when validating token; doc impro…
Anty0 Oct 3, 2025
e12649e
fix: store actor id explicitly as string
Anty0 Oct 3, 2025
15bf3f8
chore: update JwtServiceTest
Anty0 Oct 3, 2025
195c772
fix: use precalculated list of read only scopes
Anty0 Oct 3, 2025
ed09083
chore: fix interceptor docstrings
Anty0 Oct 3, 2025
523b1eb
fix: update schema
Anty0 Oct 3, 2025
67c782c
chore: fix missing field in read only interceptor test
Anty0 Oct 3, 2025
29ecf07
chore: fix project and organization interceptors to follow updated in…
Anty0 Oct 3, 2025
d651140
fix: code readability improvements
Anty0 Oct 8, 2025
9ba48c2
fix: code readability improvements
Anty0 Oct 8, 2025
c58cac8
fix: build
Anty0 Oct 8, 2025
1f3db23
fix: allow bypassing `checkLanguagePermissionByTag` and merge view pe…
Anty0 Oct 8, 2025
2eb7aa3
fix: use UserAccountDto instead of full entity
Anty0 Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -147,7 +148,18 @@ class AdministrationController(
fun generateUserToken(
@PathVariable userId: Long,
): String {
val user = userAccountService.get(userId)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JanCizmar Is there a reason to use the full UserAccount entity instead of just UserAccountDto?

return jwtService.emitToken(user.id, true)
val isAlreadyImpersonating = authenticationFacade.actingUser != null
if (isAlreadyImpersonating) {
// We don't want to recreate the Inception movie here
throw BadRequestException(Message.ALREADY_IMPERSONATING_USER)
}

val actingUser = authenticationFacade.authenticatedUser
val user = userAccountService.getDto(userId)
if (user.isAdmin() && !actingUser.isAdmin()) {
// We don't allow impersonation of admin by supporters
throw BadRequestException(Message.IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED)
}
return jwtService.emitImpersonationToken(user.id)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -77,7 +77,7 @@ class ApiKeyController(
dto: CreateApiKeyDto,
): RevealedApiKeyModel {
val project = projectService.get(dto.projectId)
if (authenticationFacade.authenticatedUser.role != UserAccount.Role.ADMIN) {
if (!authenticationFacade.authenticatedUser.isAdmin()) {
securityService.checkApiKeyScopes(dto.scopes, project)
}
return apiKeyService.create(
Expand Down Expand Up @@ -312,7 +312,7 @@ class ApiKeyController(
)
@Deprecated(message = "Don't use this endpoint, it's useless.")
val scopes: Map<String, List<String>> by lazy {
ProjectPermissionType.values()
ProjectPermissionType.entries
.associate { it -> it.name to it.availableScopes.map { it.value }.toList() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?,
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,6 +47,7 @@ class SlugController(
}

@PostMapping("/generate-organization", produces = [MediaType.APPLICATION_JSON_VALUE])
@ReadOnlyOperation
@Operation(summary = "Generate organization slug")
fun generateOrganizationSlug(
@RequestBody @Valid
Expand All @@ -55,6 +57,7 @@ class SlugController(
}

@PostMapping("/generate-project", produces = [MediaType.APPLICATION_JSON_VALUE])
@ReadOnlyOperation
@Operation(summary = "Generate project slug")
@OpenApiHideFromPublicDocs
fun generateProjectSlug(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class UserMfaController(
): JwtAuthenticationResponse {
mfaService.enableTotpFor(authenticationFacade.authenticatedUserEntity, dto)
return JwtAuthenticationResponse(
jwtService.emitToken(authenticationFacade.authenticatedUser.id, true),
jwtService.emitTokenRefreshForCurrentUser(isSuper = true),
)
}

Expand All @@ -53,7 +53,7 @@ class UserMfaController(
): JwtAuthenticationResponse {
mfaService.disableTotpFor(authenticationFacade.authenticatedUserEntity, dto)
return JwtAuthenticationResponse(
jwtService.emitToken(authenticationFacade.authenticatedUser.id, true),
jwtService.emitTokenRefreshForCurrentUser(isSuper = true),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?,
Expand All @@ -78,7 +80,9 @@ class V2InvitationController(
}

invitation.organizationRole?.let {
organizationRoleService.checkUserIsOwner(invitation.organizationRole!!.organization!!.id)
organizationRoleService.checkUserCanDeleteInvitation(
invitation.organizationRole!!.organization!!.id,
)
}

invitationService.delete(invitation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class V2UserController(
): JwtAuthenticationResponse {
userAccountService.updatePassword(authenticationFacade.authenticatedUserEntity, dto!!)
return JwtAuthenticationResponse(
jwtService.emitToken(authenticationFacade.authenticatedUser.id, true),
jwtService.emitTokenRefreshForCurrentUser(isSuper = true),
)
}

Expand Down Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -104,12 +104,12 @@ class OrganizationController(
dto: OrganizationDto,
): ResponseEntity<OrganizationModel> {
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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class ProjectsController(
@RequestBody @Valid
dto: CreateProjectRequest,
): ProjectModel {
organizationRoleService.checkUserIsOwnerOrMaintainer(dto.organizationId)
organizationRoleService.checkUserCanCreateProject(dto.organizationId)
val project = projectCreationService.createProject(dto)
if (organizationRoleService.getType(dto.organizationId) == OrganizationRoleType.MAINTAINER) {
// Maintainers get full access to projects they create
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class ProjectsTransferringController(
@PathVariable projectId: Long,
@PathVariable organizationId: Long,
) {
organizationRoleService.checkUserIsOwner(organizationId)
organizationRoleService.checkUserCanTransferProjectToOrganization(organizationId)
projectService.transferToOrganization(projectId, organizationId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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)
}

Expand Down Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.tolgee.hateoas.auth

import org.springframework.hateoas.RepresentationModel

class AuthInfoModel(
val isReadOnly: Boolean
) : RepresentationModel<AuthInfoModel>()
Original file line number Diff line number Diff line change
@@ -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<TolgeeAuthentication, AuthInfoModel>(
InitialDataController::class.java,
AuthInfoModel::class.java,
) {
override fun toModel(entity: TolgeeAuthentication): AuthInfoModel {
return AuthInfoModel(
isReadOnly = entity.isReadOnly,
)
}
}
Loading