From 1739b8671fc7ca0694a7e473d1db0e0cf48445be Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Mon, 13 Jul 2026 22:53:35 +0200 Subject: [PATCH 1/4] feat: community access to public-project organizations (#3792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the organization view floor to public-project organizations so community and direct-permission users can view them and set them as their preferred org — without a dedicated role. Opening a public project auto-switches the preferred org; for non-members this previously 403'd because org access only recognized stored membership/permission rows. - Access floor (canUserViewStrictOrPublic): direct permission / stored org role OR the org owns a publicly visible project. Anything beyond viewing still requires MEMBER+ via @RequiresOrganizationRole. - Reduced preferred-org model below MEMBER: activeCloudSubscription hidden; new PrivateOrganizationModel.limitedView drives the community project-list view. - Guest-visible surface scoped to accessible projects: org GETs (role null), project listings, languages/base-languages, glossary reads incl. CSV export (one canonical ProjectRepository.BELOW_MEMBER_ACCESSIBLE_PROJECT predicate), llm-providers (name/type/token-credit pricing only), /leave. - Preference healing writes on stale/missing preference; below-member viewers excluded from member listings, seat stats, and assignee search. - No GUEST role: the earlier virtual-role apparatus was removed; community and direct-permission users share below-member access via the floor. --- .../controllers/UserPreferencesController.kt | 2 +- .../api/v2/controllers/V2UserController.kt | 17 +- .../OrganizationLanguageController.kt | 34 +- .../component/PreferredOrganizationFacade.kt | 36 +- .../organization/PrivateOrganizationModel.kt | 2 + .../PrivateOrganizationModelAssembler.kt | 20 +- .../PreferredOrganizationCommunityTest.kt | 123 ++++++ .../PublicProjectsControllerTest.kt | 45 ++- ...PreferencesSetPreferredOrganizationTest.kt | 82 ++++ .../OrganizationCommunityAccessTest.kt | 83 ++++ .../OrganizationFloorAccessTest.kt | 362 ++++++++++++++++++ .../RefreshPreferredOrganizationTest.kt | 86 +++++ .../PrivateOrganizationModelAssemblerTest.kt | 114 ++++++ .../testDataBuilder/TestDataService.kt | 6 + .../builders/LanguageBuilder.kt | 7 + .../builders/OrganizationBuilder.kt | 7 + .../builders/ProjectBuilder.kt | 19 + .../builders/TestDataBuilder.kt | 22 ++ .../data/GlossaryGuestAccessTestData.kt | 199 ++++++++++ .../data/PublicProjectsControllerTestData.kt | 241 +++++++++++- .../data/PublicProjectsE2eData.kt | 17 + .../model/enums/OrganizationRoleType.kt | 4 + .../io/tolgee/repository/ProjectRepository.kt | 67 +++- .../repository/UserAccountRepository.kt | 3 +- .../organization/OrganizationRoleService.kt | 64 +++- .../tolgee/service/project/ProjectService.kt | 13 + .../security/UserPreferencesService.kt | 7 +- .../tolgee/unit/OrganizationRoleTypeTest.kt | 15 + .../PublicProjectsE2eDataController.kt | 2 +- .../OrganizationAuthorizationInterceptor.kt | 5 +- ...rganizationAuthorizationInterceptorTest.kt | 38 +- .../communityPreferredOrganization.cy.ts | 93 +++++ .../communityProjectsNavigation.cy.ts | 8 +- e2e/cypress/e2e/projects/publicProjects.cy.ts | 6 +- e2e/cypress/support/dataCyType.d.ts | 3 + .../glossary/GlossaryController.kt | 4 +- .../repository/glossary/GlossaryRepository.kt | 97 +++++ .../glossary/GlossaryTermRepository.kt | 14 +- .../service/glossary/GlossaryExportService.kt | 4 +- .../ee/service/glossary/GlossaryService.kt | 32 ++ .../service/glossary/GlossaryTermService.kt | 3 +- .../AuthProviderChangeControllerEeTest.kt | 27 ++ .../glossary/GlossaryGuestAccessTest.kt | 225 +++++++++++ .../layout/BaseSettingsView/SettingsMenu.tsx | 1 + .../BaseSettingsView/SettingsMenuItem.tsx | 3 + .../__tests__/organizationRole.test.ts | 14 + webapp/src/fixtures/organizationRole.ts | 8 + .../globalContext/useInitialDataService.ts | 3 + .../useOrganizationUsageService.ts | 5 +- webapp/src/service/apiSchema.generated.ts | 2 + .../organizations/OrganizationProfileView.tsx | 4 +- .../BaseOrganizationSettingsView.tsx | 13 +- webapp/src/views/projects/ProjectListView.tsx | 3 + .../projects/members/component/MemberItem.tsx | 7 +- .../component/RevokePermissionsButton.tsx | 5 +- 55 files changed, 2197 insertions(+), 129 deletions(-) create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt create mode 100644 e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts create mode 100644 ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt create mode 100644 webapp/src/fixtures/__tests__/organizationRole.test.ts create mode 100644 webapp/src/fixtures/organizationRole.ts diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt index b781f2e120b..e146082245f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt @@ -58,7 +58,7 @@ class UserPreferencesController( @PathVariable organizationId: Long, ) { val organization = organizationService.get(organizationId) - organizationRoleService.checkUserCanView(organization.id) + organizationRoleService.checkUserCanViewOrPublic(organization.id) userPreferencesService.setPreferredOrganization(organization, authenticationFacade.authenticatedUserEntity) } 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 58c51991901..31bd6938f3b 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 @@ -7,14 +7,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.activity.ActivityHolder import io.tolgee.api.isMfaEnabled -import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.component.PreferredOrganizationFacade import io.tolgee.constants.Message import io.tolgee.dtos.request.SuperTokenRequest import io.tolgee.dtos.request.UserUpdatePasswordRequestDto import io.tolgee.dtos.request.UserUpdateRequestDto import io.tolgee.exceptions.AuthenticationException import io.tolgee.hateoas.organization.PrivateOrganizationModel -import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler import io.tolgee.hateoas.organization.SimpleOrganizationModel import io.tolgee.hateoas.organization.SimpleOrganizationModelAssembler import io.tolgee.hateoas.sso.PublicSsoTenantModel @@ -67,14 +66,13 @@ class V2UserController( private val publicSsoTenantModelAssembler: PublicSsoTenantModelAssembler, private val imageUploadService: ImageUploadService, private val organizationService: OrganizationService, + private val preferredOrganizationFacade: PreferredOrganizationFacade, private val organizationRoleService: OrganizationRoleService, - private val privateOrganizationModelAssembler: PrivateOrganizationModelAssembler, private val tenantService: TenantService, private val simpleOrganizationModelAssembler: SimpleOrganizationModelAssembler, private val passwordEncoder: PasswordEncoder, private val jwtService: JwtService, private val mfaService: MfaService, - private val enabledFeaturesProvider: EnabledFeaturesProvider, private val emailVerificationService: EmailVerificationService, @Qualifier("requestActivityHolder") private val request: ActivityHolder, ) { @@ -246,15 +244,10 @@ class V2UserController( fun getManagedBy(): ResponseEntity { val userAccount = authenticationFacade.authenticatedUser val org = organizationRoleService.getManagedBy(userId = userAccount.id) ?: return ResponseEntity.noContent().build() - val view = - organizationService.findPrivateView(org.id, authenticationFacade.authenticatedUser.id) + val model = + preferredOrganizationFacade.getPrivateModel(org.id) ?: return ResponseEntity.noContent().build() - return ResponseEntity.ok( - privateOrganizationModelAssembler.toModel( - view, - enabledFeaturesProvider.get(view.organization.id), - ), - ) + return ResponseEntity.ok(model) } @PostMapping("") diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt index cbd1d8913f7..dfb3bd86ca7 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt @@ -5,8 +5,11 @@ import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.dtos.cacheable.OrganizationLanguageDto import io.tolgee.hateoas.language.OrganizationLanguageModel import io.tolgee.hateoas.language.OrganizationLanguageModelAssembler +import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.language.LanguageService +import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.service.project.ProjectService import org.springdoc.core.annotations.ParameterObject import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -29,7 +32,22 @@ class OrganizationLanguageController( private val languageService: LanguageService, private val organizationLanguageModelAssembler: OrganizationLanguageModelAssembler, private val pagedOrganizationLanguageAssembler: PagedResourcesAssembler, + private val authenticationFacade: AuthenticationFacade, + private val organizationRoleService: OrganizationRoleService, + private val projectService: ProjectService, ) { + private fun accessibleProjectIds( + organizationId: Long, + requested: List?, + ): List? { + val user = authenticationFacade.authenticatedUser + if (organizationRoleService.canUserViewAtLeastMember(user, organizationId)) { + return requested + } + val accessible = projectService.getBelowMemberAccessibleProjectIds(organizationId, user.id).toSet() + return requested?.filter { it in accessible } ?: accessible.toList() + } + @Operation( summary = "Get all languages in use by projects owned by specified organization", description = "Returns all languages in use by projects owned by specified organization", @@ -46,7 +64,13 @@ class OrganizationLanguageController( @RequestParam("projectIds") projectIds: List?, @PathVariable organizationId: Long, ): PagedModel { - val languages = languageService.getPagedByOrganization(organizationId, projectIds, pageable, search) + val languages = + languageService.getPagedByOrganization( + organizationId, + accessibleProjectIds(organizationId, projectIds), + pageable, + search, + ) return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler) } @@ -65,7 +89,13 @@ class OrganizationLanguageController( @RequestParam("projectIds") projectIds: List?, @PathVariable organizationId: Long, ): PagedModel { - val languages = languageService.getBasePagedByOrganization(organizationId, projectIds, pageable, search) + val languages = + languageService.getBasePagedByOrganization( + organizationId, + accessibleProjectIds(organizationId, projectIds), + pageable, + search, + ) return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler) } } 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 858ed9e411e..58e785716be 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt @@ -1,9 +1,11 @@ package io.tolgee.component import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.hateoas.organization.PrivateOrganizationModel import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.UserPreferencesService import org.springframework.stereotype.Component @@ -16,19 +18,31 @@ class PreferredOrganizationFacade( private val privateOrganizationModelAssembler: PrivateOrganizationModelAssembler, private val enabledFeaturesProvider: EnabledFeaturesProvider, private val organizationService: OrganizationService, + private val organizationRoleService: OrganizationRoleService, ) { fun getPreferred(): PrivateOrganizationModel? { - val preferences = userPreferencesService.findOrCreate(authenticationFacade.authenticatedUser.id) - val preferredOrganization = preferences.preferredOrganization - if (preferredOrganization != null) { - val view = - organizationService.findPrivateView(preferredOrganization.id, authenticationFacade.authenticatedUser.id) - ?: return null - return this.privateOrganizationModelAssembler.toModel( - view, - enabledFeaturesProvider.get(view.organization.id), - ) + val user = authenticationFacade.authenticatedUser + val preferences = userPreferencesService.findOrCreate(user.id) + var preferred = preferences.preferredOrganization + if (preferred == null || !organizationRoleService.canUserViewOrPublic(user.id, preferred.id)) { + // This GET path intentionally persists a heal of the missing or stale preference. + preferred = userPreferencesService.refreshPreferredOrganization(user.id) ?: return null } - return null + + return getPrivateModel(preferred.id) + } + + fun getPrivateModel(organizationId: Long): PrivateOrganizationModel? { + val user = authenticationFacade.authenticatedUser + val view = organizationService.findPrivateView(organizationId, user.id) ?: return null + val isAtLeastMember = organizationRoleService.canUserViewAtLeastMember(user, organizationId) + val limitedView = + !user.isSupporterOrAdmin() && !organizationRoleService.canUserViewStrict(user.id, organizationId) + return privateOrganizationModelAssembler.toModel( + view, + enabledFeaturesProvider.get(view.organization.id), + isAtLeastMember, + limitedView, + ) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt index f4526bb1644..8edd96c55ce 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt @@ -17,5 +17,7 @@ open class PrivateOrganizationModel( val quickStart: QuickStartModel?, @get:Schema(example = "Current active subscription info") val activeCloudSubscription: PublicCloudSubscriptionModel?, + @get:Schema(example = "Whether the user views the organization purely via public-project access") + val limitedView: Boolean, ) : RepresentationModel(), IOrganizationModel by organizationModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt index a0e0393dcaa..b5b45739f29 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt @@ -4,6 +4,7 @@ import io.tolgee.constants.Feature import io.tolgee.dtos.queryResults.organization.PrivateOrganizationView import io.tolgee.hateoas.quickStart.QuickStartModelAssembler import io.tolgee.publicBilling.CloudSubscriptionModelProvider +import io.tolgee.publicBilling.PublicCloudSubscriptionModel import org.springframework.stereotype.Component @Component @@ -15,12 +16,27 @@ class PrivateOrganizationModelAssembler( fun toModel( view: PrivateOrganizationView, features: Array, + isAtLeastMember: Boolean, + limitedView: Boolean, ): PrivateOrganizationModel { + val organizationId = view.organization.id + val organizationModel = organizationModelAssembler.toModel(view.organization) return PrivateOrganizationModel( - organizationModel = organizationModelAssembler.toModel(view.organization), + organizationModel = organizationModel, enabledFeatures = features, quickStart = view.quickStart?.let { quickStartModelAssembler.toModel(it) }, - activeCloudSubscription = cloudSubscriptionModelProvider?.provide(view.organization.id), + activeCloudSubscription = activeCloudSubscription(isAtLeastMember, organizationId), + limitedView = limitedView, ) } + + private fun activeCloudSubscription( + isAtLeastMember: Boolean, + organizationId: Long, + ): PublicCloudSubscriptionModel? { + if (!isAtLeastMember) { + return null + } + return cloudSubscriptionModelProvider?.provide(organizationId) + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt new file mode 100644 index 00000000000..5d68deef58c --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt @@ -0,0 +1,123 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.UserAccount +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PreferredOrganizationCommunityTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `community user gets the reduced organization model`() { + setPreferred(testData.nonMember, testData.otherOrg.id) + userAccount = testData.nonMember + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(true) + node("basePermissions").isNotNull + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `member gets the full organization model`() { + setPreferred(testData.otherOrgMember, testData.otherOrg.id) + userAccount = testData.otherOrgMember + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo("MEMBER") + node("limitedView").isEqualTo(false) + } + } + + @Test + fun `direct project permission user gets the reduced model with no role but a non-limited view`() { + setPreferred(testData.directPermissionUser, testData.otherOrg.id) + userAccount = testData.directPermissionUser + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(false) + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `stale preference heals to a member organization`() { + setPreferred(testData.user, testData.otherOrg.id) + unpublishOtherOrgProject() + + userAccount = testData.user + val ownOrg = testData.userAccountBuilder.defaultOrganizationBuilder.self + performAuthGet("/v2/public/initial-data").andIsOk.andAssertThatJson { + node("preferredOrganization.id").isEqualTo(ownOrg.id) + node("preferredOrganization.currentUserRole").isEqualTo("OWNER") + } + assertStoredPreference(testData.user.id, ownOrg.id) + } + + @Test + fun `stale preference with no other viewable organization heals to a created one`() { + setPreferred(testData.nonMember, testData.otherOrg.id) + unpublishOtherOrgProject() + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.nonMemberPersonalOrg.id)) + } + + userAccount = testData.nonMember + performAuthGet("/v2/public/initial-data").andIsOk.andAssertThatJson { + node("preferredOrganization.name").isEqualTo("Non Member") + node("preferredOrganization.currentUserRole").isEqualTo("OWNER") + } + executeInNewTransaction { + val preferred = userPreferencesService.find(testData.nonMember.id)!!.preferredOrganization!! + assertThat(preferred.id).isNotEqualTo(testData.otherOrg.id) + assertThat(preferred.name).isEqualTo("Non Member") + } + } + + private fun setPreferred( + user: UserAccount, + organizationId: Long, + ) { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(organizationId), + userAccountService.get(user.id), + ) + } + } + + private fun unpublishOtherOrgProject() { + executeInNewTransaction { + projectService.get(testData.otherOrgPublicProject.id).public = false + } + } + + private fun assertStoredPreference( + userId: Long, + organizationId: Long, + ) { + executeInNewTransaction { + assertThat(userPreferencesService.find(userId)!!.preferredOrganization!!.id).isEqualTo(organizationId) + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt index 8d0163115ba..13918b21b75 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt @@ -6,6 +6,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.node import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -20,26 +21,11 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { fun setup() { testData = PublicProjectsControllerTestData() testDataService.saveTestData(testData.root) - // Drive the degenerate-exclusion projects into states the JPA layer self-heals/forbids on entity - // save, via native SQL (the list query is a projection, so it never reloads them as entities). - executeInNewTransaction { - entityManager - .createNativeQuery("update project set base_language_id = null where id = :id") - .setParameter("id", testData.noBaseLanguageProject.id) - .executeUpdate() - entityManager - .createNativeQuery("update language set deleted_at = now() where project_id = :id") - .setParameter("id", testData.softDeletedBaseProject.id) - .executeUpdate() - entityManager - .createNativeQuery("update project set organization_owner_id = null where id = :id") - .setParameter("id", testData.orgLessProject.id) - .executeUpdate() - entityManager - .createNativeQuery("update project set deleted_at = now() where id = :id") - .setParameter("id", testData.deletedPublicProject.id) - .executeUpdate() - } + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) } @Test @@ -187,6 +173,25 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { } } + @Test + fun `excludes a public project of a soft-deleted organization`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + } + + @Test + fun `hasPublicProjects matches the public listing visibility exactly`() { + val defaultOrg = testData.userAccountBuilder.defaultOrganizationBuilder.self + projectService.hasPublicProjects(defaultOrg.id).assert.isTrue() + projectService.hasPublicProjects(testData.otherOrg.id).assert.isTrue() + projectService.hasPublicProjects(testData.noPublicOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.noBaseLangOnlyOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.softDeletedBaseLangOnlyOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.deletedProjectOnlyOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.softDeletedOrg.id).assert.isFalse() + } + private fun baseLanguageId(projectId: Long): Long? = executeInNewTransaction { ( diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt new file mode 100644 index 00000000000..0c461235547 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt @@ -0,0 +1,82 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UserPreferencesSetPreferredOrganizationTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `non-member sets preferred organization owning a public project`() { + userAccount = testData.nonMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null).andIsOk + assertPreferredOrganization(testData.nonMember.id, testData.otherOrg.id) + } + + @Test + fun `non-member cannot set preferred organization without public projects`() { + userAccount = testData.nonMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.noPublicOrg.id}", null) + .andIsForbidden + } + + @Test + fun `non-member cannot set preferred organization whose only public project is not publicly visible`() { + userAccount = testData.nonMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.noBaseLangOnlyOrg.id}", null) + .andIsForbidden + } + + @Test + fun `member still sets preferred organization`() { + userAccount = testData.otherOrgMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null).andIsOk + assertPreferredOrganization(testData.otherOrgMember.id, testData.otherOrg.id) + } + + @Test + fun `direct project permission user still sets preferred organization`() { + userAccount = testData.directPermissionUser + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null).andIsOk + assertPreferredOrganization(testData.directPermissionUser.id, testData.otherOrg.id) + } + + @Test + fun `server admin sets preferred organization without public projects`() { + userAccount = testData.serverAdmin + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.noPublicOrg.id}", null).andIsOk + assertPreferredOrganization(testData.serverAdmin.id, testData.noPublicOrg.id) + } + + @Test + fun `anonymous cannot set preferred organization`() { + performPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null) + .andIsForbidden + } + + private fun assertPreferredOrganization( + userId: Long, + organizationId: Long, + ) { + executeInNewTransaction { + assertThat(userPreferencesService.find(userId)!!.preferredOrganization!!.id).isEqualTo(organizationId) + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt new file mode 100644 index 00000000000..c1b04db8dac --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt @@ -0,0 +1,83 @@ +package io.tolgee.api.v2.controllers.organizationController + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.dtos.request.organization.OrganizationDto +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class OrganizationCommunityAccessTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `community user gets basic info of an organization with a public project by id`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + node("basePermissions").isNotNull + } + } + + @Test + fun `community user gets basic info of an organization with a public project by slug`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.slug}").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `community user cannot see an organization without public projects`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsNotFound + performAuthGet("/v2/organizations/${testData.noPublicOrg.slug}").andIsNotFound + } + + @Test + fun `community user cannot see an organization whose only public project is not publicly visible`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.noBaseLangOnlyOrg.id}").andIsNotFound + } + + @Test + fun `anonymous cannot see any organization`() { + performGet("/v2/organizations/${testData.otherOrg.id}").andIsForbidden + performGet("/v2/organizations/${testData.otherOrg.slug}").andIsForbidden + performGet("/v2/organizations/${testData.noPublicOrg.id}").andIsForbidden + } + + @Test + fun `community access does not open write methods on the same path`() { + userAccount = testData.nonMember + performAuthPut( + "/v2/organizations/${testData.otherOrg.id}", + OrganizationDto(name = "Hijacked", slug = testData.otherOrg.slug), + ).andIsForbidden + } + + @Test + fun `community access does not open member endpoints`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/usage").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/invitations").andIsForbidden + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt new file mode 100644 index 00000000000..ae40b85d2a9 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt @@ -0,0 +1,362 @@ +package io.tolgee.api.v2.controllers.organizationController + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.dtos.request.userAccount.UserAccountPermissionsFilters +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.domain.PageRequest + +/** Covers the org view floor (see `OrganizationRoleService.canUserViewStrictOrPublic`) and the below-member surface scoping it enables. */ +class OrganizationFloorAccessTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `a public-project floor viewer does not inherit the organization base permission`() { + userAccount = testData.storedGuest + performAuthGet("/v2/projects/${testData.otherOrgPrivateProject.id}").andIsNotFound + performAuthGet("/v2/projects/${testData.otherOrgPublicProject.id}").andIsOk.andAssertThatJson { + node("computedPermission.origin").isEqualTo("COMMUNITY") + } + } + + @Test + fun `a floor viewer sees only public projects in the organization listing`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.slug}/projects-with-stats") + .andIsOk + .andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(1) + node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) + } + } + } + + @Test + fun `member sees private and public projects in the organization listing`() { + userAccount = testData.otherOrgMember + performAuthGet("/v2/organizations/${testData.otherOrg.slug}/projects-with-stats") + .andIsOk + .andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + } + + @Test + fun `a floor viewer personal project list contains nothing from the organization`() { + userAccount = testData.storedGuest + performAuthGet("/v2/projects").andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(0) + } + } + + @Test + fun `a floor viewer is denied member endpoints`() { + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/usage").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/translation-memories").andIsForbidden + } + + @Test + fun `llm providers are floor-visible`() { + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.otherOrg.id}/llm-providers/all-available").andIsOk + } + + @Test + fun `a floor viewer sees only public-project languages, not private-project ones`() { + userAccount = testData.nonMember + val languages = orgLanguageTags("/languages") + languages.assert.contains("en") + languages.assert.doesNotContain("de") + val baseLanguages = orgLanguageTags("/base-languages") + baseLanguages.assert.contains("en") + baseLanguages.assert.doesNotContain("de") + } + + @Test + fun `a member sees languages of all organization projects, private included`() { + userAccount = testData.otherOrgMember + orgLanguageTags("/languages").assert.contains("en", "de") + orgLanguageTags("/base-languages").assert.contains("en", "de") + } + + @Test + fun `a floor viewer cannot probe a private project's languages via the projectIds filter`() { + val privateId = testData.otherOrgPrivateProject.id + userAccount = testData.nonMember + orgLanguageTags("/languages", "&projectIds=$privateId").assert.isEmpty() + orgLanguageTags("/base-languages", "&projectIds=$privateId").assert.isEmpty() + + userAccount = testData.otherOrgMember + orgLanguageTags("/languages", "&projectIds=$privateId").assert.contains("de") + orgLanguageTags("/base-languages", "&projectIds=$privateId").assert.contains("de") + } + + private fun orgLanguageTags( + path: String, + query: String = "", + ): List { + val response = + performAuthGet("/v2/organizations/${testData.otherOrg.id}$path?size=100$query").andIsOk.andReturn() + return jacksonObjectMapper() + .readTree(response.response.contentAsString) + .path("_embedded") + .path("languages") + .map { it.path("tag").asText() } + } + + @Test + fun `a floor viewer reports no role on the org endpoint`() { + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `the organizations listing stays membership-scoped - a floor viewer is not in it`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(0) + } + + userAccount = testData.otherOrgMember + performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { + node("_embedded.organizations").isArray.hasSize(1) + } + } + + @Test + fun `a member of a private-only org has standing and reports the MEMBER role`() { + userAccount = testData.privateOrgMember + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo("MEMBER") + } + } + + @Test + fun `server admin sees no role on the org endpoint`() { + userAccount = testData.serverAdmin + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `a granular scopes-only permission grants standing but no role`() { + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.granularPermissionUser.id, testData.otherOrg.id) + .assert + .isTrue() + organizationRoleService + .findType(testData.granularPermissionUser.id, testData.otherOrg.id) + .assert + .isNull() + } + } + + @Test + fun `direct permission holder sees no role on the org endpoint`() { + userAccount = testData.directPermissionUser + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `a direct-permission holder can leave the project`() { + userAccount = testData.guestWithPermission + performAuthPut("/v2/projects/${testData.otherOrgPrivateProject.id}/leave", null).andIsOk + performAuthGet("/v2/projects/${testData.otherOrgPrivateProject.id}").andIsNotFound + } + + @Test + fun `a NONE permission grants standing, reports no role and the reduced model`() { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(testData.otherOrg.id), + userAccountService.get(testData.noneOnlyUser.id), + ) + } + userAccount = testData.noneOnlyUser + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(false) + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `a floor viewer leaving the org 404s from the service, not access denial`() { + userAccount = testData.nonMember + performAuthPut("/v2/organizations/${testData.otherOrg.id}/leave", null) + .andIsNotFound + .andAssertThatJson { + node("code").isEqualTo("user_is_not_member_of_organization") + } + } + + @Test + fun `leave for a revoked-permission user removes the revoked tie`() { + userAccount = testData.revokedOnlyUser + performAuthPut("/v2/organizations/${testData.noPublicOrg.id}/leave", null).andIsOk + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.revokedOnlyUser.id, testData.noPublicOrg.id) + .assert + .isFalse() + } + } + + @Test + fun `a revoked NONE permission on a private-only org yields the reduced model`() { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(testData.noPublicOrg.id), + userAccountService.get(testData.revokedOnlyUser.id), + ) + } + userAccount = testData.revokedOnlyUser + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("id").isEqualTo(testData.noPublicOrg.id) + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(false) + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `a NONE permission keeps org access and reports no role on the org endpoint`() { + userAccount = testData.revokedOnlyUser + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `a role-less server admin gets the full model with limitedView false`() { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(testData.otherOrg.id), + userAccountService.get(testData.serverAdmin.id), + ) + } + userAccount = testData.serverAdmin + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(false) + } + } + + @Test + fun `a public-project floor viewer does not leak into org member management`() { + userAccount = testData.otherOrgOwner + val response = + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users?size=50").andIsOk.andReturn() + val usernames = + jacksonObjectMapper() + .readTree(response.response.contentAsString) + .path("_embedded") + .path("usersInOrganization") + .map { it.path("username").asText() } + usernames.assert.doesNotContain("stored_guest") + } + + @Test + fun `project member listing excludes floor viewers`() { + userAccount = testData.otherOrgOwner + performAuthGet("/v2/projects/${testData.otherOrgPublicProject.id}/users").andIsOk.andAssertThatJson { + node("_embedded.users").isArray.hasSize(3) + } + } + + @Test + fun `project stats member count excludes floor viewers`() { + userAccount = testData.otherOrgOwner + performAuthGet("/v2/projects/${testData.otherOrgPublicProject.id}/stats").andIsOk.andAssertThatJson { + node("membersCount").isEqualTo(3) + } + } + + @Test + fun `permitted-user search excludes floor viewers`() { + executeInNewTransaction { + val users = + userAccountService.findWithMinimalPermissions( + UserAccountPermissionsFilters(), + testData.otherOrgPublicProject.id, + search = null, + pageable = PageRequest.of(0, 20), + ) + users.content + .map { it.username } + .assert + .containsExactlyInAnyOrder("other_org_member", "other_org_owner", "direct_perm_user") + } + } + + @Test + fun `a role in a soft-deleted organization grants no standing`() { + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.softDeletedOrgMember.id, testData.softDeletedOrg.id) + .assert + .isFalse() + } + } + + @Test + fun `direct permission in a soft-deleted organization grants no standing`() { + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.otherOrg.id)) + } + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.directPermissionUser.id, testData.otherOrg.id) + .assert + .isFalse() + } + } + + @Test + fun `a permission-held project in a soft-deleted org is not below-member accessible`() { + executeInNewTransaction { + projectService + .getBelowMemberAccessibleProjectIds(testData.otherOrg.id, testData.guestWithPermission.id) + .assert + .contains(testData.otherOrgPrivateProject.id) + } + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.otherOrg.id)) + } + executeInNewTransaction { + projectService + .getBelowMemberAccessibleProjectIds(testData.otherOrg.id, testData.guestWithPermission.id) + .assert + .isEmpty() + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt new file mode 100644 index 00000000000..c613a1cd74a --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt @@ -0,0 +1,86 @@ +package io.tolgee.service + +import io.tolgee.AbstractSpringTest +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.model.UserAccount +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class RefreshPreferredOrganizationTest : AbstractSpringTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `keeps a community-viewable preference after leaving the organization`() { + setPreferred(testData.otherOrgMember, testData.otherOrg.id) + executeInNewTransaction { + organizationRoleService.removeUser(testData.otherOrgMember.id, testData.otherOrg.id) + } + assertThat(preferredOrganizationId(testData.otherOrgMember)).isEqualTo(testData.otherOrg.id) + } + + @Test + fun `evicts the preference after leaving an organization without public projects`() { + setPreferred(testData.noPublicOrgMember, testData.noPublicOrg.id) + executeInNewTransaction { + organizationRoleService.removeUser(testData.noPublicOrgMember.id, testData.noPublicOrg.id) + } + assertThat(preferredOrganizationId(testData.noPublicOrgMember)).isNotEqualTo(testData.noPublicOrg.id) + } + + @Test + fun `keeps a community-viewable preference after direct permission removal`() { + setPreferred(testData.directPermissionUser, testData.otherOrg.id) + executeInNewTransaction { + val permission = + permissionService + .getProjectPermissionData( + testData.otherOrgPublicProject.id, + testData.directPermissionUser.id, + ).directPermissions + permissionService.delete(permission!!.id) + } + assertThat(preferredOrganizationId(testData.directPermissionUser)).isEqualTo(testData.otherOrg.id) + } + + @Test + fun `soft-deleting the organization evicts the preference`() { + setPreferred(testData.nonMember, testData.otherOrg.id) + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.otherOrg.id)) + } + assertThat(preferredOrganizationId(testData.nonMember)).isNotEqualTo(testData.otherOrg.id) + } + + private fun setPreferred( + user: UserAccount, + organizationId: Long, + ) { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(organizationId), + userAccountService.get(user.id), + ) + } + } + + private fun preferredOrganizationId(user: UserAccount): Long? { + return executeInNewTransaction { + userPreferencesService.find(user.id)?.preferredOrganization?.id + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt b/backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt new file mode 100644 index 00000000000..d1fd1eedbf6 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt @@ -0,0 +1,114 @@ +package io.tolgee.unit + +import io.tolgee.constants.Feature +import io.tolgee.dtos.queryResults.organization.PrivateOrganizationView +import io.tolgee.hateoas.organization.OrganizationModel +import io.tolgee.hateoas.organization.OrganizationModelAssembler +import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler +import io.tolgee.hateoas.permission.PermissionModel +import io.tolgee.hateoas.quickStart.QuickStartModelAssembler +import io.tolgee.publicBilling.CloudSubscriptionModelProvider +import io.tolgee.publicBilling.PublicCloudSubscriptionModel +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class PrivateOrganizationModelAssemblerTest { + private val organizationModelAssembler = Mockito.mock(OrganizationModelAssembler::class.java) + private val quickStartModelAssembler = Mockito.mock(QuickStartModelAssembler::class.java) + private val cloudSubscriptionModelProvider = Mockito.mock(CloudSubscriptionModelProvider::class.java) + + private val underTest = + PrivateOrganizationModelAssembler( + organizationModelAssembler, + quickStartModelAssembler, + cloudSubscriptionModelProvider, + ) + + private val view = Mockito.mock(PrivateOrganizationView::class.java) + + private val organizationModel = + OrganizationModel( + id = 42L, + name = "The org", + slug = "the-org", + description = null, + basePermissions = Mockito.mock(PermissionModel::class.java), + currentUserRole = null, + avatar = null, + ) + + private fun setup() { + val organizationView = + Mockito.mock(io.tolgee.dtos.queryResults.organization.OrganizationView::class.java) + whenever(organizationView.id).thenReturn(42L) + whenever(view.organization).thenReturn(organizationView) + whenever(view.quickStart).thenReturn(null) + whenever(organizationModelAssembler.toModel(any())).thenReturn(organizationModel) + } + + @Test + fun `a below-member reader gets no subscription and maps the passed limitedView`() { + setup() + + val model = + underTest.toModel( + view, + arrayOf(Feature.GLOSSARY, Feature.PREMIUM_SUPPORT), + isAtLeastMember = false, + limitedView = true, + ) + + model.enabledFeatures + .toSet() + .assert + .isEqualTo(setOf(Feature.GLOSSARY, Feature.PREMIUM_SUPPORT)) + model.currentUserRole.assert.isNull() + model.limitedView.assert.isEqualTo(true) + model.activeCloudSubscription.assert.isNull() + verify(cloudSubscriptionModelProvider, never()).provide(any()) + } + + @Test + fun `a below-member with standing gets no subscription but a non-limited view`() { + setup() + + val model = + underTest.toModel( + view, + arrayOf(Feature.GLOSSARY), + isAtLeastMember = false, + limitedView = false, + ) + + model.limitedView.assert.isEqualTo(false) + model.activeCloudSubscription.assert.isNull() + verify(cloudSubscriptionModelProvider, never()).provide(any()) + } + + @Test + fun `a member gets features, the cloud subscription and a non-limited view`() { + setup() + val subscription = Mockito.mock(PublicCloudSubscriptionModel::class.java) + whenever(cloudSubscriptionModelProvider.provide(42L)).thenReturn(subscription) + + val model = + underTest.toModel( + view, + arrayOf(Feature.PREMIUM_SUPPORT, Feature.GLOSSARY), + isAtLeastMember = true, + limitedView = false, + ) + + model.enabledFeatures + .toSet() + .assert + .isEqualTo(setOf(Feature.PREMIUM_SUPPORT, Feature.GLOSSARY)) + model.limitedView.assert.isEqualTo(false) + model.activeCloudSubscription.assert.isEqualTo(subscription) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 458618db4f9..f0725561b70 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -141,6 +141,12 @@ class TestDataService( } updateLanguageStats(builder) + + if (builder.afterSaveRawStates.isNotEmpty()) { + executeInNewTransaction(transactionManager) { + builder.afterSaveRawStates.forEach { it(entityManager) } + } + } } finally { activityHolder.enableAutoCompletion = true bypassableActivityListeners.forEach { it.bypass = false } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt index 1011af974af..c0819dbf2ab 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt @@ -4,6 +4,7 @@ import io.tolgee.development.testDataBuilder.EntityDataBuilder import io.tolgee.development.testDataBuilder.FT import io.tolgee.model.Language import io.tolgee.model.qa.LanguageQaConfig +import java.util.Date class LanguageBuilder( val projectBuilder: ProjectBuilder, @@ -24,4 +25,10 @@ class LanguageBuilder( data.qaConfig = builder return builder } + + fun setDeletedAt(deletedAt: Date = Date()) { + projectBuilder.testDataBuilder.rawUpdateAfterSave("update language set deleted_at = :deletedAt where id = :id") { + mapOf("deletedAt" to deletedAt, "id" to self.id) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt index 3693de9686e..2b472c0be08 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt @@ -14,6 +14,7 @@ import io.tolgee.model.glossary.Glossary import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace import io.tolgee.model.translationMemory.TranslationMemory import org.springframework.core.io.ClassPathResource +import java.util.Date class OrganizationBuilder( val testDataBuilder: TestDataBuilder, @@ -87,4 +88,10 @@ class OrganizationBuilder( fun addGlossary(ft: FT) = addOperation(data.glossaries, ft) fun addTranslationMemory(ft: FT) = addOperation(data.translationMemories, ft) + + fun setDeletedAt(deletedAt: Date = Date()) { + testDataBuilder.rawUpdateAfterSave("update organization set deleted_at = :deletedAt where id = :id") { + mapOf("deletedAt" to deletedAt, "id" to self.id) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt index 8a65e02ea2e..651e4378203 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt @@ -38,6 +38,7 @@ import io.tolgee.model.translationMemory.TranslationMemory import io.tolgee.model.translationMemory.TranslationMemoryType import io.tolgee.model.webhook.WebhookConfig import org.springframework.core.io.ClassPathResource +import java.util.Date class ProjectBuilder( organizationOwner: Organization? = null, @@ -315,4 +316,22 @@ class ProjectBuilder( } if (!alreadyDeclared) addProjectTm() } + + fun setDeletedAt(deletedAt: Date = Date()) { + testDataBuilder.rawUpdateAfterSave("update project set deleted_at = :deletedAt where id = :id") { + mapOf("deletedAt" to deletedAt, "id" to self.id) + } + } + + fun clearBaseLanguage() { + testDataBuilder.rawUpdateAfterSave("update project set base_language_id = null where id = :id") { + mapOf("id" to self.id) + } + } + + fun clearOrganizationOwner() { + testDataBuilder.rawUpdateAfterSave("update project set organization_owner_id = null where id = :id") { + mapOf("id" to self.id) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt index f54bba13058..60488eaf40f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt @@ -4,6 +4,7 @@ import io.tolgee.model.Organization import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType +import jakarta.persistence.EntityManager class TestDataBuilder( fn: (TestDataBuilder.() -> Unit) = {}, @@ -28,6 +29,27 @@ class TestDataBuilder( val data = DATA() + /** + * Raw-state fixups TestDataService runs after the graph is saved — for states the JPA layer + * self-heals or forbids on entity save (soft deletes, missing base language). + */ + val afterSaveRawStates = mutableListOf<(EntityManager) -> Unit>() + + fun rawStateAfterSave(fn: (EntityManager) -> Unit) { + afterSaveRawStates.add(fn) + } + + fun rawUpdateAfterSave( + sql: String, + params: () -> Map, + ) { + rawStateAfterSave { em -> + val query = em.createNativeQuery(sql) + params().forEach { (name, value) -> query.setParameter(name, value) } + query.executeUpdate() + } + } + fun addUserAccountWithoutOrganization(ft: UserAccount.() -> Unit): UserAccountBuilder { val builder = UserAccountBuilder(this) data.userAccounts.add(builder) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt new file mode 100644 index 00000000000..4f8bec1a1ec --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt @@ -0,0 +1,199 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.LanguageBuilder +import io.tolgee.development.testDataBuilder.builders.ProjectBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm + +class GlossaryGuestAccessTestData : BaseTestData() { + lateinit var organization: Organization + lateinit var publicProject: Project + lateinit var privateProject: Project + lateinit var publicGlossary: Glossary + lateinit var publicGlossaryTerm: GlossaryTerm + lateinit var privateGlossary: Glossary + lateinit var privateGlossaryTerm: GlossaryTerm + lateinit var mixedGlossary: Glossary + lateinit var degenerateProject: Project + lateinit var degenerateGlossary: Glossary + lateinit var softDeletedProject: Project + lateinit var softDeletedProjectGlossary: Glossary + lateinit var softDeletedBaseLangProject: Project + lateinit var softDeletedBaseLangGlossary: Glossary + lateinit var storedGuest: UserAccount + lateinit var virtualGuest: UserAccount + lateinit var directPermissionUser: UserAccount + lateinit var noneOnlyUser: UserAccount + + init { + root.apply { + storedGuest = + addUserAccount { + username = "glossary_stored_guest" + name = "Glossary Stored Guest" + }.self + + virtualGuest = + addUserAccount { + username = "glossary_virtual_guest" + name = "Glossary Virtual Guest" + }.self + + directPermissionUser = + addUserAccount { + username = "glossary_direct_perm_user" + name = "Glossary Direct Perm User" + }.self + + noneOnlyUser = + addUserAccount { + username = "glossary_none_only_user" + name = "Glossary None Only User" + }.self + + userAccountBuilder.defaultOrganizationBuilder.build { + organization = self + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Guest visible public project" + public = true + }.build { + publicProject = self + addBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "A members only project" + }.build { + privateProject = self + addBaseLanguage() + addLanguage { + name = "German" + tag = "de" + originalName = "Deutsch" + } + addPermission { + user = this@GlossaryGuestAccessTestData.directPermissionUser + type = ProjectPermissionType.TRANSLATE + } + addPermission { + user = this@GlossaryGuestAccessTestData.storedGuest + type = ProjectPermissionType.NONE + } + addPermission { + user = this@GlossaryGuestAccessTestData.noneOnlyUser + type = ProjectPermissionType.NONE + } + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Degenerate public project" + public = true + }.build { + degenerateProject = self + addBaseLanguage() + clearBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Soft-deleted public project" + public = true + }.build { + softDeletedProject = self + addBaseLanguage() + setDeletedAt() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Soft-deleted base language public project" + public = true + }.build { + softDeletedBaseLangProject = self + addBaseLanguage().setDeletedAt() + } + + userAccountBuilder.defaultOrganizationBuilder.build { + publicGlossary = + addGlossary { + name = "Public project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.publicProject) + publicGlossaryTerm = + addTerm { + description = "Public term" + }.build { + addTranslation { + languageTag = "en" + text = "public term" + } + }.self + }.self + + privateGlossary = + addGlossary { + name = "Private project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.privateProject) + privateGlossaryTerm = + addTerm { + description = "Members-only term" + }.build { + addTranslation { + languageTag = "en" + text = "members-only term" + } + }.self + }.self + + mixedGlossary = + addGlossary { + name = "Mixed assignment glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.publicProject) + assignProject(this@GlossaryGuestAccessTestData.privateProject) + }.self + + degenerateGlossary = + addGlossary { + name = "Degenerate project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.degenerateProject) + }.self + + softDeletedProjectGlossary = + addGlossary { + name = "Soft-deleted project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.softDeletedProject) + }.self + + softDeletedBaseLangGlossary = + addGlossary { + name = "Soft-deleted base language glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.softDeletedBaseLangProject) + }.self + } + } + } + + private fun ProjectBuilder.addBaseLanguage(): LanguageBuilder { + return addLanguage { + name = "English" + tag = "en" + originalName = "English" + this@addBaseLanguage.self.baseLanguage = this + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt index 2ff78893b9f..23a9390d130 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt @@ -1,10 +1,13 @@ package io.tolgee.development.testDataBuilder.data +import io.tolgee.development.testDataBuilder.builders.LanguageBuilder import io.tolgee.development.testDataBuilder.builders.ProjectBuilder import io.tolgee.model.Organization 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.Scope class PublicProjectsControllerTestData : BaseTestData() { val privateProject: Project get() = project @@ -21,13 +24,42 @@ class PublicProjectsControllerTestData : BaseTestData() { lateinit var orgLessProject: Project lateinit var deletedPublicProject: Project + lateinit var nonMemberPersonalOrg: Organization + lateinit var otherOrgMember: UserAccount + lateinit var otherOrgOwner: UserAccount + lateinit var storedGuest: UserAccount + lateinit var otherOrgPrivateProject: Project + lateinit var serverAdmin: UserAccount + + lateinit var guestWithPermission: UserAccount + lateinit var granularPermissionUser: UserAccount + lateinit var noneOnlyUser: UserAccount + lateinit var revokedOnlyUser: UserAccount + + lateinit var noPublicOrg: Organization + lateinit var noPublicOrgMember: UserAccount + lateinit var noPublicOrgOwner: UserAccount + lateinit var privateOrgMember: UserAccount + + lateinit var noBaseLangOnlyOrg: Organization + lateinit var noBaseLangOnlyOrgProject: Project + lateinit var softDeletedBaseLangOnlyOrg: Organization + lateinit var softDeletedBaseLangOnlyOrgProject: Project + lateinit var deletedProjectOnlyOrg: Organization + lateinit var deletedProjectOnlyOrgProject: Project + lateinit var softDeletedOrg: Organization + lateinit var softDeletedOrgPublicProject: Project + lateinit var softDeletedOrgMember: UserAccount + init { root.apply { - nonMember = + val nonMemberBuilder = addUserAccount { username = "non_member" name = "Non Member" - }.self + } + nonMember = nonMemberBuilder.self + nonMemberPersonalOrg = nonMemberBuilder.defaultOrganizationBuilder.self directPermissionUser = addUserAccount { @@ -43,9 +75,62 @@ class PublicProjectsControllerTestData : BaseTestData() { addBaseLanguage() } + otherOrgMember = + addUserAccount { + username = "other_org_member" + name = "Other Org Member" + }.self + + val storedGuestBuilder = + addUserAccount { + username = "stored_guest" + name = "Stored Guest" + } + storedGuest = storedGuestBuilder.self + + otherOrgOwner = + addUserAccount { + username = "other_org_owner" + name = "Other Org Owner" + }.self + + guestWithPermission = + addUserAccount { + username = "guest_with_permission" + name = "Guest With Permission" + }.self + + granularPermissionUser = + addUserAccount { + username = "granular_perm_user" + name = "Granular Perm User" + }.self + + noneOnlyUser = + addUserAccount { + username = "none_only_user" + name = "None Only User" + }.self + + serverAdmin = + addUserAccount { + username = "server_admin" + name = "Server Admin" + role = UserAccount.Role.ADMIN + }.self + otherOrg = addOrganization { name = "Vibrant translators" + }.build { + addRole { + user = this@PublicProjectsControllerTestData.otherOrgMember + type = OrganizationRoleType.MEMBER + } + addRole { + user = this@PublicProjectsControllerTestData.otherOrgOwner + type = OrganizationRoleType.OWNER + } }.self addProject(organizationOwner = otherOrg) { @@ -60,12 +145,156 @@ class PublicProjectsControllerTestData : BaseTestData() { } } + addProject(organizationOwner = otherOrg) { + name = "Other org private project" + }.build { + otherOrgPrivateProject = self + val privateProjectSelf = self + addLanguage { + name = "German" + tag = "de" + originalName = "Deutsch" + privateProjectSelf.baseLanguage = this + } + addPermission { + user = this@PublicProjectsControllerTestData.guestWithPermission + type = ProjectPermissionType.TRANSLATE + } + addPermission { + user = this@PublicProjectsControllerTestData.granularPermissionUser + scopes = arrayOf(Scope.TRANSLATIONS_VIEW) + } + addPermission { + user = this@PublicProjectsControllerTestData.noneOnlyUser + type = ProjectPermissionType.NONE + } + } + + noPublicOrgMember = + addUserAccount { + username = "no_public_org_member" + name = "No Public Org Member" + }.self + + revokedOnlyUser = + addUserAccount { + username = "revoked_only_user" + name = "Revoked Only User" + }.self + + noPublicOrgOwner = + addUserAccount { + username = "no_public_org_owner" + name = "No Public Org Owner" + }.self + + privateOrgMember = + addUserAccount { + username = "private_org_member" + name = "Private Org Member" + }.self + + noPublicOrg = + addOrganization { + name = "Members only outfit" + }.build { + addRole { + user = this@PublicProjectsControllerTestData.noPublicOrgMember + type = OrganizationRoleType.MEMBER + } + addRole { + user = this@PublicProjectsControllerTestData.noPublicOrgOwner + type = OrganizationRoleType.OWNER + } + addRole { + user = this@PublicProjectsControllerTestData.privateOrgMember + type = OrganizationRoleType.MEMBER + } + }.self + + addProject(organizationOwner = noPublicOrg) { + name = "Members only private project" + }.build { + addBaseLanguage() + addPermission { + user = this@PublicProjectsControllerTestData.revokedOnlyUser + type = ProjectPermissionType.NONE + } + } + + noBaseLangOnlyOrg = + addOrganization { + name = "No base lang only org" + }.self + + addProject(organizationOwner = noBaseLangOnlyOrg) { + name = "No base lang only org project" + public = true + }.build { + noBaseLangOnlyOrgProject = self + addBaseLanguage() + clearBaseLanguage() + } + + softDeletedBaseLangOnlyOrg = + addOrganization { + name = "Soft deleted base lang only org" + }.self + + addProject(organizationOwner = softDeletedBaseLangOnlyOrg) { + name = "Soft deleted base lang only org project" + public = true + }.build { + softDeletedBaseLangOnlyOrgProject = self + addBaseLanguage().setDeletedAt() + } + + deletedProjectOnlyOrg = + addOrganization { + name = "Deleted project only org" + }.self + + addProject(organizationOwner = deletedProjectOnlyOrg) { + name = "Deleted project only org project" + public = true + }.build { + deletedProjectOnlyOrgProject = self + addBaseLanguage() + setDeletedAt() + } + + softDeletedOrgMember = + addUserAccount { + username = "soft_deleted_org_member" + name = "Soft Deleted Org Member" + }.self + + softDeletedOrg = + addOrganization { + name = "Soft deleted org" + }.build { + addRole { + user = this@PublicProjectsControllerTestData.softDeletedOrgMember + type = OrganizationRoleType.MEMBER + } + setDeletedAt() + }.self + + addProject(organizationOwner = softDeletedOrg) { + name = "Soft deleted org public project" + public = true + }.build { + softDeletedOrgPublicProject = self + addBaseLanguage() + } + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { name = "No base language project" public = true }.build { noBaseLanguageProject = self addBaseLanguage() + clearBaseLanguage() } addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { @@ -73,7 +302,7 @@ class PublicProjectsControllerTestData : BaseTestData() { public = true }.build { softDeletedBaseProject = self - addBaseLanguage() + addBaseLanguage().setDeletedAt() } addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { @@ -82,6 +311,7 @@ class PublicProjectsControllerTestData : BaseTestData() { }.build { orgLessProject = self addBaseLanguage() + clearOrganizationOwner() } addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { @@ -90,12 +320,13 @@ class PublicProjectsControllerTestData : BaseTestData() { }.build { deletedPublicProject = self addBaseLanguage() + setDeletedAt() } } } - private fun ProjectBuilder.addBaseLanguage() { - addLanguage { + private fun ProjectBuilder.addBaseLanguage(): LanguageBuilder { + return addLanguage { name = "English" tag = "en" originalName = "English" diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt index 49e739377d1..574594def25 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt @@ -8,9 +8,26 @@ import io.tolgee.development.testDataBuilder.builders.ProjectBuilder */ class PublicProjectsE2eData( count: Int = 6, + includeForeignOrgProject: Boolean = true, ) : BaseTestData("publicProjectsUser", "Private project") { init { root.apply { + val communityUserBuilder = + addUserAccount { + username = "communityUser" + name = "Community User" + } + + // a dropped organizationId filter in the org-scoped listing would surface this foreign project + if (includeForeignOrgProject) { + addProject(organizationOwner = communityUserBuilder.defaultOrganizationBuilder.self) { + name = "Community Outsider" + public = true + }.build { + addBaseLanguage() + } + } + listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta").take(count).forEach { suffix -> addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { name = "Community $suffix" 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 5a7d50c38e5..afc8705c1d8 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,5 +1,9 @@ package io.tolgee.model.enums +/** + * `OrganizationRole.type` persists this enum by ORDINAL — new values must only ever be appended, + * and declaration order can never change. + */ enum class OrganizationRoleType( val isReadOnly: Boolean, ) { 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 240b02bef36..90832d2009c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -49,14 +49,42 @@ interface ProjectRepository : JpaRepository { or bl.tag = :#{#filters.filterBaseLanguageTag} ) """ + + /** + * Expects aliases `r` (Project), `bl` (base language), `o` (owning organization). + * `o.deletedAt is null` is load-bearing: org soft-delete leaves projects undeleted and + * public, so dropping it re-opens guest access to soft-deleted organizations. + */ + const val PUBLIC_PROJECT_VISIBILITY = """ + r.public = true and r.deletedAt is null and r.organizationOwner is not null + and r.baseLanguage is not null and bl.deletedAt is null + and o.deletedAt is null + """ + + /** + * A project a below-member viewer may read: public, or held via any permission row. + * + * `r.deletedAt is null and o.deletedAt is null` are top-level so they also guard the permission-row + * branch, which [PUBLIC_PROJECT_VISIBILITY] does not — do not "deduplicate" them against the public + * branch, or that branch re-opens to soft-deleted projects/orgs. Splice consumers must join `r`/`bl`/`o`. + */ + const val BELOW_MEMBER_ACCESSIBLE_PROJECT = """ + ( + r.deletedAt is null and o.deletedAt is null and ( + ($PUBLIC_PROJECT_VISIBILITY) + or exists (select perm.id from Permission perm where perm.user.id = :userId and perm.project = r) + ) + ) + """ } @Query( - """select r, p, o, role from Project r + """select r, p, o, role from Project r left join fetch Permission p on p.project = r and p.user.id = :userAccountId left join fetch Organization o on r.organizationOwner = o left join fetch OrganizationRole role on role.organization = o and role.user.id = :userAccountId - where (p is not null or (role is not null)) and r.deletedAt is null + where (p is not null or role is not null) + and r.deletedAt is null order by r.name """, ) @@ -68,9 +96,11 @@ interface ProjectRepository : JpaRepository { left join UserAccount ua on ua.id = :userAccountId left join o.basePermission 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' or ua.role = 'SUPPORTER') and :organizationId is not null)) + (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' or ua.role = 'SUPPORTER') and :organizationId is not null) or + (:organizationId is not null and $PUBLIC_PROJECT_VISIBILITY)) 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),'%'))) @@ -108,8 +138,7 @@ interface ProjectRepository : JpaRepository { */ @Query( """$BASE_VIEW_QUERY - where r.public = true and r.deletedAt is null and r.organizationOwner is not null - and r.baseLanguage is not null and bl.deletedAt is null + where $PUBLIC_PROJECT_VISIBILITY 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),'%'))) @@ -122,6 +151,30 @@ interface ProjectRepository : JpaRepository { @Param("search") search: String? = null, ): Page + @Query( + """select count(r) > 0 from Project r + left join r.baseLanguage bl + left join r.organizationOwner o + where $PUBLIC_PROJECT_VISIBILITY + and o.id = :organizationId + """, + ) + fun hasPublicProjects(organizationId: Long): Boolean + + /** Ids of the org's projects a below-member viewer may read (see [BELOW_MEMBER_ACCESSIBLE_PROJECT]). */ + @Query( + """select r.id from Project r + left join r.baseLanguage bl + left join r.organizationOwner o + where o.id = :organizationId + and $BELOW_MEMBER_ACCESSIBLE_PROJECT + """, + ) + fun getBelowMemberAccessibleProjectIds( + organizationId: Long, + userId: Long, + ): List + fun findAllByOrganizationOwnerId(organizationOwnerId: Long): List @Query("select p.id from Project p where p.organizationOwner.deletedAt is not null") diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index 6a06da8e310..800ec7b2a94 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -263,7 +263,8 @@ interface UserAccountRepository : JpaRepository { left join Project r on r.id = :projectId left join ua.permissions p on p.project.id = :projectId left join ua.organizationRoles orl on orl.organization = r.organizationOwner - where r.id = :projectId and (p is not null or orl is not null) + where r.id = :projectId + and (p is not null or orl is not null) and (:exceptUserId is null or ua.id <> :exceptUserId) and ((lower(ua.name) like lower(concat('%', cast(:search as text),'%')) 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 90b2c53082e..1d232d650f8 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 @@ -18,6 +18,7 @@ import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.repository.OrganizationRepository import io.tolgee.repository.OrganizationRoleRepository import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.project.ProjectService import io.tolgee.service.security.PermissionService import io.tolgee.service.security.UserAccountService import io.tolgee.service.security.UserPreferencesService @@ -41,14 +42,56 @@ class OrganizationRoleService( @param:Lazy private val userPreferencesService: UserPreferencesService, @param:Lazy + private val projectService: ProjectService, + @param:Lazy private val self: OrganizationRoleService, private val cacheManager: CacheManager, ) { + /** + * Strict = no public-project floor; do not substitute the `…OrPublic` variant, which silently + * over-grants org visibility to any authenticated user on a public-project org. + */ fun canUserViewStrict( userId: Long, organizationId: Long, ) = this.organizationRepository.canUserView(userId, organizationId) + fun checkUserCanViewOrPublic(organizationId: Long) { + if (canUserViewOrPublic(authenticationFacade.authenticatedUser, organizationId)) { + return + } + throw PermissionException(Message.USER_CANNOT_VIEW_THIS_ORGANIZATION) + } + + fun canUserViewOrPublic( + userId: Long, + organizationId: Long, + ): Boolean { + val user = userAccountService.findDto(userId) ?: return false + return canUserViewOrPublic(user, organizationId) + } + + fun canUserViewOrPublic( + user: UserAccountDto, + organizationId: Long, + ): Boolean = user.isSupporterOrAdmin() || canUserViewStrictOrPublic(user.id, organizationId) + + fun canUserViewAtLeastMember( + user: UserAccountDto, + organizationId: Long, + ): Boolean = user.isSupporterOrAdmin() || hasAnyOrganizationRole(user.id, organizationId) + + /** + * The org view floor with no `isSupporterOrAdmin()` short-circuit, on purpose — + * [io.tolgee.security.authorization.OrganizationAuthorizationInterceptor] calls this variant so + * supporter/admin access instead routes through its `canBypass` (see there). Do not add an admin + * short-circuit or swap in [canUserViewOrPublic]. + */ + fun canUserViewStrictOrPublic( + userId: Long, + organizationId: Long, + ): Boolean = canUserViewStrict(userId, organizationId) || projectService.hasPublicProjects(organizationId) + fun checkUserCanView(organizationId: Long) { checkUserCanView( authenticationFacade.authenticatedUser, @@ -70,22 +113,6 @@ class OrganizationRoleService( } } - fun canUserView( - userId: Long, - organizationId: Long, - ): Boolean { - val userAccountDto = - userAccountService.findDto(userId) - ?: return false - - return canUserView(userAccountDto, organizationId) - } - - fun canUserView( - user: UserAccountDto, - organizationId: Long, - ) = user.isSupporterOrAdmin() || this.organizationRepository.canUserView(user.id, organizationId) - /** * Verifies the user has a role equal or higher than a given role. * @@ -154,10 +181,7 @@ class OrganizationRoleService( fun hasAnyOrganizationRole( userId: Long, organizationId: Long, - ): Boolean { - val role = self.getDto(organizationId, userId).type - return role != null - } + ): Boolean = findType(userId, organizationId) != null fun isUserOwner( userId: Long, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index b370548bcbd..9d5baa580ae 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -489,6 +489,19 @@ class ProjectService( return projects.map { ProjectWithLanguagesView.fromProjectView(it, null) } } + @Transactional(readOnly = true) + fun hasPublicProjects(organizationId: Long): Boolean { + return projectRepository.hasPublicProjects(organizationId) + } + + @Transactional(readOnly = true) + fun getBelowMemberAccessibleProjectIds( + organizationId: Long, + userId: Long, + ): List { + return projectRepository.getBelowMemberAccessibleProjectIds(organizationId, userId) + } + @CacheEvict(cacheNames = [Caches.PROJECTS], allEntries = true) fun saveAll(projects: Collection): MutableList = projectRepository.saveAll(projects) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt index 39a8a1ee0d8..d899fe554d9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt @@ -8,6 +8,7 @@ import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import io.tolgee.util.tryUntilItDoesntBreakConstraint +import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service @Service @@ -16,6 +17,7 @@ class UserPreferencesService( private val userPreferencesRepository: UserPreferencesRepository, private val userAccountService: UserAccountService, private val organizationService: OrganizationService, + @param:Lazy private val organizationRoleService: OrganizationRoleService, ) { fun setLanguage( @@ -99,10 +101,7 @@ class UserPreferencesService( fun refreshPreferredOrganization(preferences: UserPreferences): Organization? { val canUserView = preferences.preferredOrganization?.let { po -> - organizationRoleService.canUserView( - preferences.userAccount.id, - po.id, - ) + organizationRoleService.canUserViewOrPublic(preferences.userAccount.id, po.id) } ?: false if (!canUserView) { diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt new file mode 100644 index 00000000000..1d195841298 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt @@ -0,0 +1,15 @@ +package io.tolgee.unit + +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class OrganizationRoleTypeTest { + @Test + fun `declaration order is pinned because the entity persists the type by ordinal`() { + OrganizationRoleType.entries + .map { it.name } + .assert + .containsExactly("MEMBER", "OWNER", "MAINTAINER") + } +} diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt index 1748c86f274..745bb072697 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt @@ -18,6 +18,6 @@ class PublicProjectsE2eDataController( @GetMapping(value = ["/generate-few"]) @Transactional fun generateFew(): StandardTestDataResult { - return generatingService.generate(PublicProjectsE2eData(count = 5).root) + return generatingService.generate(PublicProjectsE2eData(count = 5, includeForeignOrgProject = false).root) } } 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 73522b37d4d..6ba812e78ca 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 @@ -36,7 +36,8 @@ import org.springframework.web.method.HandlerMethod /** * 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. + * By default it enforces the org view floor (see [OrganizationRoleService.canUserViewStrictOrPublic]). + * Anything beyond viewing must require a role via `@RequiresOrganizationRole`. */ @Component class OrganizationAuthorizationInterceptor( @@ -71,7 +72,7 @@ class OrganizationAuthorizationInterceptor( requiredRole ?: "read-only", ) - if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { + if (!organizationRoleService.canUserViewStrictOrPublic(userId, organization.id)) { if (!canBypass(request, handler)) { logger.debug( "Rejecting access to org#{} for user#{} - No view permissions", 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 9cf3e5569ca..594a8021f4f 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 @@ -36,6 +36,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -120,14 +122,14 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it hides the organization if the user cannot see it`() { Mockito - .`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)) .thenReturn(false) mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsNotFound mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsNotFound Mockito - .`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)) .thenReturn(true) mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk @@ -136,7 +138,7 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `rejects access if the user does not have a sufficiently high role`() { Mockito - .`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)) .thenReturn(true) Mockito .`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)) @@ -153,7 +155,7 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it allows supporter to bypass checks for read-only organization endpoints`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(false) Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) performReadOnlyRequests { all -> all.andIsOk } @@ -161,7 +163,7 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it does not let supporter bypass checks for write organization endpoints`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(false) Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) performWriteRequests { all -> all.andIsForbidden } @@ -169,13 +171,37 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it allows admin to access any endpoint`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(false) Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.ADMIN) performReadOnlyRequests { all -> all.andIsOk } performWriteRequests { all -> all.andIsOk } } + @Test + fun `the interceptor consults the raw floor, never the admin-aware variant`() { + // Guards the raw-vs-admin split: swapping the interceptor to the admin-aware canUserViewOrPublic + // would fold in isSupporterOrAdmin() and suppress the admin-privileges audit log. The mocked + // outcome cannot catch that, so pin the method identity instead. + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(true) + + mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk + + verify(organizationRoleService).canUserViewStrictOrPublic(1337L, 1337L) + verify(organizationRoleService, never()).canUserViewOrPublic(any(), any()) + } + + @Test + fun `the view floor grants default endpoints but not role-required ones`() { + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(true) + Mockito + .`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)) + .thenReturn(false) + + mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk + mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsForbidden + } + private fun performReadOnlyRequests(condition: (ResultActions) -> Unit) { // GET method mockMvc.perform(get("/v2/organizations/1337/default-perms")).andSatisfies(condition) diff --git a/e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts b/e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts new file mode 100644 index 00000000000..16abd654201 --- /dev/null +++ b/e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts @@ -0,0 +1,93 @@ +import { HOST } from '../../common/constants'; +import { login } from '../../common/apiCalls/common'; +import { publicProjectsData } from '../../common/apiCalls/testData/testData'; +import { + assertSwitchedToOrganization, + gcy, + gcyAdvanced, + switchToOrganization, +} from '../../common/shared'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Community preferred organization', () => { + let organizations: Record; + + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generateStandard().then((res) => { + organizations = {}; + res.body.organizations.forEach((org) => { + organizations[org.name] = org; + }); + }); + login('communityUser'); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + const openPublicProject = () => { + cy.visit(`${HOST}/public-projects`); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').contains('Community Alpha').click(); + cy.url().should('match', /\/projects\/[0-9]+/); + waitForGlobalLoading(); + }; + + it('switches a guest to the owning organization and shows its public projects', () => { + openPublicProject(); + gcy('notistack-snackbar').should('not.exist'); + assertSwitchedToOrganization('publicProjectsUser'); + + cy.visit(`${HOST}/`); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 6); + cy.contains('Community Outsider').should('not.exist'); + cy.contains('Private project').should('not.exist'); + gcy('global-plus-button').should('not.exist'); + gcy('project-list-more-button').should('not.exist'); + }); + + it('restores the full member experience after switching back to the own organization', () => { + openPublicProject(); + assertSwitchedToOrganization('publicProjectsUser'); + + cy.visit(`${HOST}/`); + waitForGlobalLoading(); + switchToOrganization('Community User'); + waitForGlobalLoading(); + gcy('global-plus-button').should('exist'); + gcy('dashboard-projects-list-item').should('have.length', 1); + gcy('project-list-more-button').should('exist'); + }); + + it('keeps the project page usable when the preferred-organization switch fails', () => { + cy.intercept('PUT', '**/v2/user-preferences/set-preferred-organization/*', { + statusCode: 403, + body: { code: 'operation_not_permitted' }, + }); + openPublicProject(); + cy.url().should('match', /\/projects\/[0-9]+/); + gcy('global-base-view-content').should('exist'); + }); + + it('shows glossaries but hides translation memories in the foreign org settings', () => { + openPublicProject(); + cy.visit( + `${HOST}/organizations/${organizations['publicProjectsUser'].slug}/profile` + ); + waitForGlobalLoading(); + gcyAdvanced({ value: 'settings-menu-item', item: 'profile' }).should( + 'be.visible' + ); + gcyAdvanced({ value: 'settings-menu-item', item: 'glossaries' }).should( + 'be.visible' + ); + gcyAdvanced({ + value: 'settings-menu-item', + item: 'translation-memories', + }).should('not.exist'); + gcy('organization-profile-leave-button').should('be.disabled'); + }); +}); diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts index ede478f4c7d..d97ea3f8194 100644 --- a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -212,8 +212,8 @@ describe('Community projects list content', () => { }); it('lists public projects across orgs with the public badge, hiding private ones', () => { - gcy('dashboard-projects-list-item').should('have.length', 6); - gcy('project-list-public-badge').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); + gcy('project-list-public-badge').should('have.length', 7); cy.contains('Community Alpha').should('be.visible'); cy.contains('Community Zeta').should('be.visible'); cy.contains('Private project').should('not.exist'); @@ -232,9 +232,9 @@ describe('Community projects list content', () => { waitForGlobalLoading(); gcy('dashboard-projects-list-item').should('have.length', 1); gcy('global-list-search').find('input').clear(); - // The length-6 assertion must come first: it retries until the debounced cleared-search + // The list-length assertion must come first: it retries until the debounced cleared-search // refetch lands, so the focus check below runs only after the field has (or hasn't) remounted. - gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); gcy('global-list-search').find('input').should('be.focused'); }); }); diff --git a/e2e/cypress/e2e/projects/publicProjects.cy.ts b/e2e/cypress/e2e/projects/publicProjects.cy.ts index bfa1d838ca8..bbad2974746 100644 --- a/e2e/cypress/e2e/projects/publicProjects.cy.ts +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -32,8 +32,8 @@ describe('Public projects view', () => { it('lists public projects with the public badge + org and hides private ones', () => { publicProjectsData.generateStandard(); visit(); - gcy('dashboard-projects-list-item').should('have.length', 6); - gcy('project-list-public-badge').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); + gcy('project-list-public-badge').should('have.length', 7); gcy('global-search-field').should('exist'); cy.contains('Community Alpha').should('be.visible'); cy.contains('Community Zeta').should('be.visible'); @@ -53,7 +53,7 @@ describe('Public projects view', () => { gcy('global-search-field').should('exist'); gcy('global-search-field').find('input').clear(); - gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); gcy('global-search-field').should('exist'); }); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index a4ff22aa7ac..18773b2c844 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -253,6 +253,7 @@ declare namespace DataCy { "global-search-field": true; "global-search-field-clear": true; "global-user-menu-button": true; + "glossaries": true; "glossaries-empty-add-button": true; "glossaries-list-more-button": true; "glossary-batch-delete-button": true; @@ -538,6 +539,7 @@ declare namespace DataCy { "plan-limit-dialog-close": true; "plan-limit-exceeded-popover": true; "plan_seat_limit_exceeded_while_accepting_invitation_message": true; + "profile": true; "project-ai-prompt-dialog-description-input": true; "project-ai-prompt-dialog-save": true; "project-base-language-tm-conflict-confirm": true; @@ -868,6 +870,7 @@ declare namespace DataCy { "translation-label-add": true; "translation-label-control": true; "translation-label-delete": true; + "translation-memories": true; "translation-memories-empty-add-button": true; "translation-memories-list-more-button": true; "translation-memory-delete-button": true; 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 b8c61789fd5..5d2edc60900 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 @@ -35,6 +35,7 @@ import org.springframework.data.web.PagedResourcesAssembler import org.springframework.hateoas.CollectionModel import org.springframework.hateoas.PagedModel import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -46,6 +47,7 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController +@CrossOrigin(origins = ["*"]) @RequestMapping("/v2/organizations/{organizationId:[0-9]+}") @Tag(name = "Glossary") class GlossaryController( @@ -176,6 +178,6 @@ class GlossaryController( ): CollectionModel { val organization = organizationHolder.organization val glossary = glossaryService.get(organization.id, glossaryId) - return simpleProjectModelAssembler.toCollectionModel(glossary.assignedProjects) + return simpleProjectModelAssembler.toCollectionModel(glossaryService.getAssignedProjectsForCurrentUser(glossary)) } } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt index cfc855c752d..4adfc833558 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt @@ -1,7 +1,9 @@ package io.tolgee.ee.repository.glossary import io.tolgee.ee.data.glossary.GlossaryWithStats +import io.tolgee.model.Project import io.tolgee.model.glossary.Glossary +import io.tolgee.repository.ProjectRepository import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -13,6 +15,25 @@ import org.springframework.stereotype.Repository @Repository @Lazy interface GlossaryRepository : JpaRepository { + companion object { + /** + * The canonical [io.tolgee.repository.ProjectRepository.BELOW_MEMBER_ACCESSIBLE_PROJECT]; consuming + * queries must join its `r` (assigned project) / `bl` (base language) / `o` (org) aliases + `:userId`. + */ + const val ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE = ProjectRepository.BELOW_MEMBER_ACCESSIBLE_PROJECT + + /** A glossary a below-member reader may read: has at least one [ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE] project. Expects alias `g`. */ + const val BELOW_MEMBER_ACCESSIBLE = """ + exists ( + select r.id from Glossary g2 + join g2.assignedProjects r + left join r.baseLanguage bl + join r.organizationOwner o + where g2.id = g.id and $ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE + ) + """ + } + @Query( """ from Glossary @@ -26,6 +47,21 @@ interface GlossaryRepository : JpaRepository { glossaryId: Long, ): Glossary? + @Query( + """ + select g from Glossary g + where g.organizationOwner.id = :organizationId + and g.organizationOwner.deletedAt is null + and g.id = :glossaryId + and ($BELOW_MEMBER_ACCESSIBLE) + """, + ) + fun findBelowMemberAccessible( + organizationId: Long, + glossaryId: Long, + userId: Long?, + ): Glossary? + @Query( """ from Glossary @@ -49,6 +85,22 @@ interface GlossaryRepository : JpaRepository { search: String?, ): Page + @Query( + """ + select g from Glossary g + where g.organizationOwner.id = :organizationId + and g.organizationOwner.deletedAt is null + and (lower(g.name) like lower(concat('%', coalesce(:search, ''), '%')) or :search is null) + and ($BELOW_MEMBER_ACCESSIBLE) + """, + ) + fun findByOrganizationIdBelowMemberPaged( + organizationId: Long, + userId: Long?, + pageable: Pageable, + search: String?, + ): Page + @Query( """ select g.id as id, @@ -70,6 +122,35 @@ interface GlossaryRepository : JpaRepository { search: String?, ): Page + /** + * Below-member variant: joins only the projects the user can access, so private assigned projects + * leak neither into `firstAssignedProjectName` nor the count. + */ + @Query( + """ + select g.id as id, + g.name as name, + g.baseLanguageTag as baseLanguageTag, + min(r.name) as firstAssignedProjectName, + count(r) as assignedProjectsCount + from Glossary g + join g.assignedProjects r + left join r.baseLanguage bl + join r.organizationOwner o + where g.organizationOwner.id = :organizationId + and g.organizationOwner.deletedAt is null + and (lower(g.name) like lower(concat('%', coalesce(:search, ''), '%')) or :search is null) + and $ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE + group by g.id + """, + ) + fun findByOrganizationIdWithStatsBelowMemberPaged( + organizationId: Long, + userId: Long?, + pageable: Pageable, + search: String?, + ): Page + @Query( """ select gp.project_id @@ -80,6 +161,22 @@ interface GlossaryRepository : JpaRepository { ) fun findAssignedProjectsIdsByGlossaryId(glossaryId: Long): Set + @Query( + """ + select r from Glossary g + join g.assignedProjects r + left join r.baseLanguage bl + join r.organizationOwner o + where g.id = :glossaryId + and $ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE + order by r.name + """, + ) + fun findBelowMemberAccessibleAssignedProjects( + glossaryId: Long, + userId: Long?, + ): List + @Query( """ select distinct g diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt index c1746bde38d..92623734e05 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt @@ -12,18 +12,8 @@ import org.springframework.stereotype.Repository @Repository @Lazy interface GlossaryTermRepository : JpaRepository { - @Query( - """ - from GlossaryTerm - where glossary.organizationOwner.id = :organizationId - and glossary.organizationOwner.deletedAt is null - and glossary.id = :glossaryId - and id = :id - """, - ) - fun find( - organizationId: Long, - glossaryId: Long, + fun findByGlossaryAndId( + glossary: Glossary, id: Long, ): GlossaryTerm? diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt index ddd47062b17..871980fc698 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt @@ -35,14 +35,14 @@ class GlossaryExportService( } private fun getOrganizationLanguageTagsForExport(glossary: Glossary): Set { - val assignedProjectIds = glossaryService.getAssignedProjectsIds(glossary) + val assignedProjectIds = glossaryService.getAssignedProjectsForCurrentUser(glossary).map { it.id } if (assignedProjectIds.isEmpty()) { return languageService.getTagsByOrganization(glossary.organizationOwner.id) } return languageService.getTagsByOrganizationAndProjectIds( glossary.organizationOwner.id, - assignedProjectIds.toList(), + assignedProjectIds, ) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt index 4af3f052372..75bac159a8c 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt @@ -10,7 +10,9 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.model.Organization import io.tolgee.model.Project import io.tolgee.model.glossary.Glossary +import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.GlossaryCleanupService +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.project.ProjectService import jakarta.transaction.Transactional import org.springframework.context.annotation.Primary @@ -25,6 +27,8 @@ class GlossaryService( private val glossaryTermTranslationService: GlossaryTermTranslationService, private val projectService: ProjectService, private val currentDateProvider: CurrentDateProvider, + private val authenticationFacade: AuthenticationFacade, + private val organizationRoleService: OrganizationRoleService, ) : GlossaryCleanupService { fun findAll(organizationId: Long): List { return glossaryRepository.findByOrganizationId(organizationId) @@ -35,6 +39,9 @@ class GlossaryService( pageable: Pageable, search: String?, ): Page { + if (isBelowMemberReader(organizationId)) { + return glossaryRepository.findByOrganizationIdBelowMemberPaged(organizationId, currentUserId(), pageable, search) + } return glossaryRepository.findByOrganizationIdPaged(organizationId, pageable, search) } @@ -50,6 +57,14 @@ class GlossaryService( pageable: Pageable, search: String?, ): Page { + if (isBelowMemberReader(organizationId)) { + return glossaryRepository.findByOrganizationIdWithStatsBelowMemberPaged( + organizationId, + currentUserId(), + pageable, + search, + ) + } return glossaryRepository.findByOrganizationIdWithStatsPaged(organizationId, pageable, search) } @@ -57,9 +72,19 @@ class GlossaryService( organizationId: Long, glossaryId: Long, ): Glossary? { + if (isBelowMemberReader(organizationId)) { + return glossaryRepository.findBelowMemberAccessible(organizationId, glossaryId, currentUserId()) + } return glossaryRepository.find(organizationId, glossaryId) } + private fun isBelowMemberReader(organizationId: Long): Boolean { + val user = authenticationFacade.authenticatedUserOrNull ?: return true + return !organizationRoleService.canUserViewAtLeastMember(user, organizationId) + } + + private fun currentUserId(): Long? = authenticationFacade.authenticatedUserOrNull?.id + fun get( organizationId: Long, glossaryId: Long, @@ -135,6 +160,13 @@ class GlossaryService( return glossaryRepository.findAssignedProjectsIdsByGlossaryId(glossary.id) } + fun getAssignedProjectsForCurrentUser(glossary: Glossary): Collection { + if (isBelowMemberReader(glossary.organizationOwner.id)) { + return glossaryRepository.findBelowMemberAccessibleAssignedProjects(glossary.id, currentUserId()) + } + return glossary.assignedProjects + } + @Transactional fun assignProject( organizationId: Long, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt index 283f68e9622..0fa450f7e65 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt @@ -38,7 +38,8 @@ class GlossaryTermService( glossaryId: Long, termId: Long, ): GlossaryTerm? { - return glossaryTermRepository.find(organizationId, glossaryId, termId) + val glossary = glossaryService.find(organizationId, glossaryId) ?: return null + return glossaryTermRepository.findByGlossaryAndId(glossary, termId) } fun findAll( diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt index 9fedd98f1cf..ae7bb6beaad 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt @@ -7,7 +7,10 @@ import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsNoContent import io.tolgee.fixtures.andIsNotFound import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -92,6 +95,30 @@ class AuthProviderChangeControllerEeTest : AuthorizedControllerTest() { } } + @Test + fun `accepting organizations sso keeps an existing organization role`() { + executeInNewTransaction { + organizationRoleService.grantRoleToUser( + userAccountService.get(testData.userChangeNoneToSsoOrganizations.id), + organizationService.get(testData.organization.id), + OrganizationRoleType.MEMBER, + ) + } + userAccount = testData.userChangeNoneToSsoOrganizations + performAuthPost("/v2/auth-provider/change", mapOf("id" to testData.changeNoneToSsoOrganizations.identifier)).andIsOk + executeInNewTransaction { + organizationRoleService + .findType(testData.userChangeNoneToSsoOrganizations.id, testData.organization.id) + .assert + .isEqualTo(OrganizationRoleType.MEMBER) + organizationRoleService + .getManagedBy(testData.userChangeNoneToSsoOrganizations.id) + ?.id + .assert + .isEqualTo(testData.organization.id) + } + } + @Test fun `accepts change google to global sso`() { userAccount = testData.userChangeGoogleToSsoGlobal diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt new file mode 100644 index 00000000000..44f19867b2a --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt @@ -0,0 +1,225 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.tolgee.constants.Feature +import io.tolgee.development.testDataBuilder.data.GlossaryGuestAccessTestData +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.ee.service.glossary.GlossaryService +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.model.UserAccount +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +@AutoConfigureMockMvc +class GlossaryGuestAccessTest : AuthorizedControllerTest() { + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + @Autowired + private lateinit var glossaryService: GlossaryService + + lateinit var testData: GlossaryGuestAccessTestData + + @BeforeEach + fun setup() { + enabledFeaturesProvider.forceEnabled = setOf(Feature.GLOSSARY) + testData = GlossaryGuestAccessTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) + userAccount = null + enabledFeaturesProvider.forceEnabled = null + } + + @Test + fun `guest lists only glossaries assigned to accessible projects`() { + // storedGuest and noneOnlyUser hold permission rows on the private project, so its glossaries + // count as accessible for them; the degenerate (no base language) public project is not + // publicly visible, so its glossary stays hidden for everyone below MEMBER + assertGuestList( + testData.virtualGuest, + "Mixed assignment glossary", + "Public project glossary", + ) + assertGuestList( + testData.storedGuest, + "Mixed assignment glossary", + "Private project glossary", + "Public project glossary", + ) + assertGuestList( + testData.noneOnlyUser, + "Mixed assignment glossary", + "Private project glossary", + "Public project glossary", + ) + } + + @Test + fun `guest stats cover only accessible projects of a mixed-assignment glossary`() { + userAccount = testData.virtualGuest + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries-with-stats?sort=name&sort=id", + ).andIsOk.andAssertThatJson { + node("_embedded.glossaries") { + isArray.hasSize(2) + node("[0].name").isEqualTo("Mixed assignment glossary") + node("[0].assignedProjectsCount").isEqualTo(1) + node("[0].firstAssignedProjectName").isEqualTo("Guest visible public project") + node("[1].name").isEqualTo("Public project glossary") + } + } + } + + @Test + fun `guest sees only accessible assigned projects of a mixed-assignment glossary`() { + userAccount = testData.virtualGuest + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries/${testData.mixedGlossary.id}/assigned-projects", + ).andIsOk.andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(1) + node("[0].name").isEqualTo("Guest visible public project") + } + } + } + + @Test + fun `direct project permission holder reads glossaries of accessible projects`() { + assertGuestList( + testData.directPermissionUser, + "Mixed assignment glossary", + "Private project glossary", + "Public project glossary", + ) + } + + @Test + fun `guest can read an accessible glossary but not a members-only one`() { + userAccount = testData.virtualGuest + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.publicGlossary.id}") + .andIsOk + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}") + .andIsNotFound + // a permission row on the private project makes its glossary accessible + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}") + .andIsOk + } + + @Test + fun `guest terms access follows the glossary accessibility`() { + userAccount = testData.virtualGuest + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries/${testData.publicGlossary.id}/terms", + ).andIsOk + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}/terms", + ).andIsNotFound + } + + @Test + fun `glossaries of soft-deleted projects or base languages stay hidden from guests`() { + userAccount = testData.virtualGuest + val base = "/v2/organizations/${testData.organization.id}/glossaries" + performAuthGet("$base/${testData.softDeletedProjectGlossary.id}").andIsNotFound + performAuthGet("$base/${testData.softDeletedProjectGlossary.id}/terms").andIsNotFound + performAuthGet("$base/${testData.softDeletedProjectGlossary.id}/assigned-projects").andIsNotFound + performAuthGet("$base/${testData.softDeletedBaseLangGlossary.id}").andIsNotFound + performAuthGet("$base/${testData.softDeletedBaseLangGlossary.id}/terms").andIsNotFound + performAuthGet("$base/${testData.softDeletedBaseLangGlossary.id}/assigned-projects").andIsNotFound + } + + @Test + fun `guest single-term reads follow the glossary accessibility`() { + userAccount = testData.virtualGuest + val publicTermPath = + "/v2/organizations/${testData.organization.id}" + + "/glossaries/${testData.publicGlossary.id}/terms/${testData.publicGlossaryTerm.id}" + val privateTermPath = + "/v2/organizations/${testData.organization.id}" + + "/glossaries/${testData.privateGlossary.id}/terms/${testData.privateGlossaryTerm.id}" + performAuthGet(publicTermPath).andIsOk + performAuthGet("$publicTermPath/translations/en").andIsOk + performAuthGet(privateTermPath).andIsNotFound + performAuthGet("$privateTermPath/translations/en").andIsNotFound + } + + @Test + fun `an unauthenticated context takes the guest-restricted path`() { + executeInNewTransaction { + glossaryService.find(testData.organization.id, testData.privateGlossary.id).assert.isNull() + glossaryService.find(testData.organization.id, testData.publicGlossary.id).assert.isNotNull() + } + } + + @Test + fun `guest cannot create a glossary`() { + userAccount = testData.storedGuest + performAuthPost( + "/v2/organizations/${testData.organization.id}/glossaries", + mapOf("name" to "Nope", "baseLanguageTag" to "en"), + ).andIsForbidden + } + + @Test + fun `member sees all glossaries`() { + userAccount = testData.user + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries") + .andIsOk + .andAssertThatJson { + node("_embedded.glossaries").isArray.hasSize(6) + } + } + + @Test + fun `guest export omits a private co-assigned project's languages`() { + // mixedGlossary is assigned to a public project (en) and a private project (en, de); a floor + // viewer must not receive the private-only "de" column, while a member does. + userAccount = testData.virtualGuest + exportLanguageHeaders(testData.mixedGlossary.id).assert.doesNotContain("de") + + userAccount = testData.user + exportLanguageHeaders(testData.mixedGlossary.id).assert.contains("de") + } + + private fun exportLanguageHeaders(glossaryId: Long): List { + val csv = + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/$glossaryId/export") + .andIsOk + .andReturn() + .response.contentAsString + val headers = csv.lines()[0].split(",").map { it.trim().removeSurrounding("\"") } + return headers.drop(6) + } + + private fun assertGuestList( + user: UserAccount, + vararg expectedNames: String, + ) { + userAccount = user + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries?sort=name&sort=id") + .andIsOk + .andAssertThatJson { + node("_embedded.glossaries") { + isArray.hasSize(expectedNames.size) + expectedNames.forEachIndexed { index, name -> + node("[$index].name").isEqualTo(name) + } + } + } + } +} diff --git a/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx b/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx index dc0882d9f66..249f77ad239 100644 --- a/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx +++ b/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx @@ -27,6 +27,7 @@ export const SettingsMenu: React.FC> = ({ matchAsPrefix={true} linkTo={item.link} text={item.label} + dataCyItem={item['data-cy']} /> ))} diff --git a/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx b/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx index 8bd29270a04..6c4e794e8db 100644 --- a/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx +++ b/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx @@ -34,6 +34,7 @@ type Props = { selected?: boolean; matchAsPrefix?: boolean; hidden?: boolean; + dataCyItem?: string; }; export const SettingsMenuItem: React.FC> = ({ @@ -42,6 +43,7 @@ export const SettingsMenuItem: React.FC> = ({ selected, matchAsPrefix, hidden, + dataCyItem, }) => { const match = useLocation(); @@ -57,6 +59,7 @@ export const SettingsMenuItem: React.FC> = ({ aria-label={text} to={linkTo as string} data-cy="settings-menu-item" + data-cy-item={dataCyItem} tabIndex={hidden ? -1 : undefined} className={clsx('link', { selected: isSelected })} > diff --git a/webapp/src/fixtures/__tests__/organizationRole.test.ts b/webapp/src/fixtures/__tests__/organizationRole.test.ts new file mode 100644 index 00000000000..298899820f2 --- /dev/null +++ b/webapp/src/fixtures/__tests__/organizationRole.test.ts @@ -0,0 +1,14 @@ +import { isAtLeastMemberOrgRole } from '../organizationRole'; + +describe('isAtLeastMemberOrgRole', () => { + it('treats MEMBER and above as members', () => { + expect(isAtLeastMemberOrgRole('MEMBER')).toBe(true); + expect(isAtLeastMemberOrgRole('MAINTAINER')).toBe(true); + expect(isAtLeastMemberOrgRole('OWNER')).toBe(true); + }); + + it('does not treat a missing role as a member', () => { + expect(isAtLeastMemberOrgRole(undefined)).toBe(false); + expect(isAtLeastMemberOrgRole(null)).toBe(false); + }); +}); diff --git a/webapp/src/fixtures/organizationRole.ts b/webapp/src/fixtures/organizationRole.ts new file mode 100644 index 00000000000..1e2501c265b --- /dev/null +++ b/webapp/src/fixtures/organizationRole.ts @@ -0,0 +1,8 @@ +import { components } from 'tg.service/apiSchema.generated'; + +type OrganizationRole = + components['schemas']['PrivateOrganizationModel']['currentUserRole']; + +export const isAtLeastMemberOrgRole = ( + role: OrganizationRole | undefined | null +): boolean => Boolean(role); diff --git a/webapp/src/globalContext/useInitialDataService.ts b/webapp/src/globalContext/useInitialDataService.ts index 8fe4c566331..eb007e4915b 100644 --- a/webapp/src/globalContext/useInitialDataService.ts +++ b/webapp/src/globalContext/useInitialDataService.ts @@ -156,6 +156,9 @@ export const useInitialDataService = () => { const data = await preferredOrganizationLoadable.mutateAsync({}); setQuickStart(data.quickStart); setOrganization(data); + } catch { + // the global API error handler already surfaced the failure; callers fire-and-forget + // this on every project open, so a rethrow would be an unhandled rejection } finally { setOrganizationLoading(false); } diff --git a/webapp/src/globalContext/useOrganizationUsageService.ts b/webapp/src/globalContext/useOrganizationUsageService.ts index d98dab1910d..3f92ee8f8f7 100644 --- a/webapp/src/globalContext/useOrganizationUsageService.ts +++ b/webapp/src/globalContext/useOrganizationUsageService.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { components } from 'tg.service/apiSchema.generated'; import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; type OrganizationModel = components['schemas']['OrganizationModel']; type UsageModel = components['schemas']['PublicUsageModel']; @@ -14,7 +15,9 @@ export const useOrganizationUsageService = ({ organization, enabled, }: Props) => { - const isOrganizationMember = Boolean(organization?.currentUserRole); + const isOrganizationMember = isAtLeastMemberOrgRole( + organization?.currentUserRole + ); const [organizationUsage, setOrganizationUsage] = useState< UsageModel | undefined >(undefined); diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index ed1fd5be77b..7f2e2857edf 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -5192,6 +5192,8 @@ export interface components { )[]; /** Format: int64 */ id: number; + /** @example false */ + limitedView: boolean; /** @example Beautiful organization */ name: string; organizationModel?: components["schemas"]["OrganizationModel"]; diff --git a/webapp/src/views/organizations/OrganizationProfileView.tsx b/webapp/src/views/organizations/OrganizationProfileView.tsx index 5be54641d39..b97ccf954d4 100644 --- a/webapp/src/views/organizations/OrganizationProfileView.tsx +++ b/webapp/src/views/organizations/OrganizationProfileView.tsx @@ -54,7 +54,7 @@ export const OrganizationProfileView: FunctionComponent< const isAdmin = useIsAdmin(); const readOnly = organization.data?.currentUserRole !== 'OWNER' && !isAdmin; - const notMember = !organization.data?.currentUserRole; + const hasNoRole = !organization.data?.currentUserRole; const onSubmit = (values: OrganizationBody) => { const toSave = { @@ -150,7 +150,7 @@ export const OrganizationProfileView: FunctionComponent< color="secondary" variant="outlined" onClick={handleLeave} - disabled={notMember} + disabled={hasNoRole} > diff --git a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx index 521f80b7b50..3dd56396dd9 100644 --- a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx +++ b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx @@ -16,6 +16,7 @@ import { usePreferredOrganization, } from 'tg.globalContext/helpers'; import { CriticalUsageCircle } from 'tg.ee'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; type OrganizationModel = components['schemas']['OrganizationModel']; @@ -54,6 +55,7 @@ export const BaseOrganizationSettingsView: React.FC< [PARAMS.ORGANIZATION_SLUG]: organizationSlug, }), label: t('organization_menu_profile'), + 'data-cy': 'profile', }, ]; @@ -77,12 +79,14 @@ export const BaseOrganizationSettingsView: React.FC< [PARAMS.ORGANIZATION_SLUG]: organizationSlug, }), label: t('organization_menu_glossaries'), + 'data-cy': 'glossaries', }); - // TM browse is gated server-side to actual org members — hide the link for project-only - // viewers so they don't land on a 403. Glossary stays visible for parity since it carries - // no virtual cross-project content. - if (preferredOrganization?.currentUserRole != null || isAdminOrSupporter) { + // hide the link; below-MEMBER users would hit a 403 on the TM endpoints + if ( + isAtLeastMemberOrgRole(preferredOrganization?.currentUserRole) || + isAdminOrSupporter + ) { menuItems.push({ link: LINKS.ORGANIZATION_TRANSLATION_MEMORIES.build({ [PARAMS.ORGANIZATION_SLUG]: organizationSlug, @@ -91,6 +95,7 @@ export const BaseOrganizationSettingsView: React.FC< 'organization_menu_translation_memories', 'Translation memories' ), + 'data-cy': 'translation-memories', }); } diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index 2ce81b8f0a2..523283759a0 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -25,6 +25,8 @@ export const ProjectListView = () => { const [search, setSearch] = useState(''); const { preferredOrganization } = usePreferredOrganization(); + const limitedView = Boolean(preferredOrganization?.limitedView); + const listPermitted = useApiQuery({ url: '/v2/organizations/{slug}/projects-with-stats', method: 'get', @@ -92,6 +94,7 @@ export const ProjectListView = () => { > > = ({ messages.success(); } - const isOrganzationMember = Boolean(user.organizationRole); + const isOrganizationMember = isAtLeastMemberOrgRole(user.organizationRole); const hasDirectPermissions = Boolean(user.directPermission); async function handleResetToOrganization() { @@ -136,9 +137,9 @@ export const MemberItem: React.FC> = ({ permissions: user.computedPermission, onSubmit: handleSubmit, isInheritedFromOrganization: - !hasDirectPermissions && isOrganzationMember, + !hasDirectPermissions && isOrganizationMember, onResetToOrganization: - hasDirectPermissions && isOrganzationMember + hasDirectPermissions && isOrganizationMember ? handleResetToOrganization : undefined, }} diff --git a/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx b/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx index 145222d4e84..4cccb4ace42 100644 --- a/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx +++ b/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx @@ -11,11 +11,12 @@ import { useApiMutation } from 'tg.service/http/useQueryApi'; import { useLeaveProject } from 'tg.views/projects/useLeaveProject'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { messageService } from 'tg.service/MessageService'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; const RevokePermissionsButton = (props: { user: components['schemas']['UserAccountInProjectModel']; }) => { - const hasOrganizationRole = !!props.user.organizationRole; + const isAtLeastMember = isAtLeastMemberOrgRole(props.user.organizationRole); const project = useProject(); const currentUser = useUser(); const { satisfiesPermission } = useProjectPermissions(); @@ -65,7 +66,7 @@ const RevokePermissionsButton = (props: { let isDisabled = false; let tooltip = undefined as ReactElement | undefined; - if (hasOrganizationRole) { + if (isAtLeastMember) { tooltip = ; isDisabled = true; } else if (currentUser!.id === props.user.id) { From 65f88ff5db752a050c58b8950fa8fe3b1d286c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 17 Jul 2026 15:20:39 +0200 Subject: [PATCH 2/4] test: assert org member listing via JSON DSL with a non-vacuity anchor The member-management leak check parsed the MockMvc response by hand and asserted doesNotContain on the extracted usernames, which would also pass against an empty list. Anchor the array as non-empty and assert per-element via andAssertThatJson. --- .../OrganizationFloorAccessTest.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt index ae40b85d2a9..d779a21760c 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt @@ -10,6 +10,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.node import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert +import net.javacrumbs.jsonunit.core.internal.Node.JsonMap import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -274,15 +275,14 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { @Test fun `a public-project floor viewer does not leak into org member management`() { userAccount = testData.otherOrgOwner - val response = - performAuthGet("/v2/organizations/${testData.otherOrg.id}/users?size=50").andIsOk.andReturn() - val usernames = - jacksonObjectMapper() - .readTree(response.response.contentAsString) - .path("_embedded") - .path("usersInOrganization") - .map { it.path("username").asText() } - usernames.assert.doesNotContain("stored_guest") + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users?size=50").andIsOk.andAssertThatJson { + node("_embedded.usersInOrganization") { + isArray.isNotEmpty() + isArray.allSatisfy { + (it as JsonMap)["username"].assert.isNotEqualTo("stored_guest") + } + } + } } @Test From 7bfb9e54a4abd6b65d9972a5db2bab255c4e523a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 17 Jul 2026 16:32:55 +0200 Subject: [PATCH 3/4] refactor: move org project-access scoping into SecurityService The accessibleProjectIds helper lived as a private function on OrganizationLanguageController, which pulled AuthenticationFacade, OrganizationRoleService and ProjectService into the controller just to make an access decision. Move it to SecurityService as getAccessibleProjectIdsInOrganization so the controller only depends on SecurityService and the scoping rule is reusable. --- .../OrganizationLanguageController.kt | 24 +++-------------- .../service/security/SecurityService.kt | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt index dfb3bd86ca7..4e8b9d014be 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt @@ -5,11 +5,9 @@ import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.dtos.cacheable.OrganizationLanguageDto import io.tolgee.hateoas.language.OrganizationLanguageModel import io.tolgee.hateoas.language.OrganizationLanguageModelAssembler -import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.language.LanguageService -import io.tolgee.service.organization.OrganizationRoleService -import io.tolgee.service.project.ProjectService +import io.tolgee.service.security.SecurityService import org.springdoc.core.annotations.ParameterObject import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -32,22 +30,8 @@ class OrganizationLanguageController( private val languageService: LanguageService, private val organizationLanguageModelAssembler: OrganizationLanguageModelAssembler, private val pagedOrganizationLanguageAssembler: PagedResourcesAssembler, - private val authenticationFacade: AuthenticationFacade, - private val organizationRoleService: OrganizationRoleService, - private val projectService: ProjectService, + private val securityService: SecurityService, ) { - private fun accessibleProjectIds( - organizationId: Long, - requested: List?, - ): List? { - val user = authenticationFacade.authenticatedUser - if (organizationRoleService.canUserViewAtLeastMember(user, organizationId)) { - return requested - } - val accessible = projectService.getBelowMemberAccessibleProjectIds(organizationId, user.id).toSet() - return requested?.filter { it in accessible } ?: accessible.toList() - } - @Operation( summary = "Get all languages in use by projects owned by specified organization", description = "Returns all languages in use by projects owned by specified organization", @@ -67,7 +51,7 @@ class OrganizationLanguageController( val languages = languageService.getPagedByOrganization( organizationId, - accessibleProjectIds(organizationId, projectIds), + securityService.getAccessibleProjectIdsInOrganization(organizationId, projectIds), pageable, search, ) @@ -92,7 +76,7 @@ class OrganizationLanguageController( val languages = languageService.getBasePagedByOrganization( organizationId, - accessibleProjectIds(organizationId, projectIds), + securityService.getAccessibleProjectIdsInOrganization(organizationId, projectIds), pageable, search, ) 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 ffe2830038d..e4384bfd716 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 @@ -25,8 +25,10 @@ import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.branching.BranchService import io.tolgee.service.label.LabelService import io.tolgee.service.language.LanguageService +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.project.ProjectFeatureGuard import io.tolgee.service.project.ProjectFeatureRegistry +import io.tolgee.service.project.ProjectService import io.tolgee.service.task.ITaskService import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy @@ -60,6 +62,30 @@ class SecurityService( @Autowired private lateinit var projectFeatureGuard: ProjectFeatureGuard + @set:Autowired + @set:Lazy + lateinit var organizationRoleService: OrganizationRoleService + + @set:Autowired + @set:Lazy + lateinit var projectService: ProjectService + + /** + * `null` in and out means "no filter" — a below-member viewer never gets `null` back, so callers + * must pass the result on to a query that scopes by it rather than treating `null` as "all". + */ + fun getAccessibleProjectIdsInOrganization( + organizationId: Long, + requestedProjectIds: List?, + ): List? { + val user = authenticationFacade.authenticatedUser + if (organizationRoleService.canUserViewAtLeastMember(user, organizationId)) { + return requestedProjectIds + } + val accessible = projectService.getBelowMemberAccessibleProjectIds(organizationId, user.id).toSet() + return requestedProjectIds?.filter { it in accessible } ?: accessible.toList() + } + fun checkAnyProjectPermission(projectId: Long) { if ( getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() && From bfe364f26e354d75e3726627baf9e569af5e426c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Sat, 18 Jul 2026 13:56:32 +0200 Subject: [PATCH 4/4] fix: exclude revoked NONE permissions from below-member org access A NONE permission is a revocation (no effective scopes) but the below-member floor treated any permission row as access: a user whose only tie to an org was a NONE permission on a private project could read that project's languages and glossary terms and retain org standing, even though the project-listing query already excludes NONE. Exclude NONE consistently in BELOW_MEMBER_ACCESSIBLE_PROJECT (languages + glossary) and in the org-standing queries (canUserView / findAllPermitted / findPreferred), matching the listing. Also: add discriminating floor/glossary tests for the NONE behaviour, drop a now-dead glossary accessor, minor cleanups, an OpenAPI boolean-example fix, and trim over-long comments to single-line invariants. --- .../component/PreferredOrganizationFacade.kt | 3 +- .../organization/PrivateOrganizationModel.kt | 5 +- .../OrganizationFloorAccessTest.kt | 61 +++++++++++++------ .../builders/TestDataBuilder.kt | 5 +- .../data/PublicProjectsE2eData.kt | 5 -- .../repository/OrganizationRepository.kt | 10 +-- .../io/tolgee/repository/ProjectRepository.kt | 20 ++---- .../organization/OrganizationRoleService.kt | 10 --- .../service/security/SecurityService.kt | 10 ++- .../OrganizationAuthorizationInterceptor.kt | 1 + ...rganizationAuthorizationInterceptorTest.kt | 3 - .../repository/glossary/GlossaryRepository.kt | 19 ------ .../ee/service/glossary/GlossaryService.kt | 4 -- .../glossary/GlossaryGuestAccessTest.kt | 10 +-- webapp/src/service/apiSchema.generated.ts | 5 +- .../organizations/OrganizationProfileView.tsx | 3 +- webapp/src/views/projects/ProjectListView.tsx | 4 +- 17 files changed, 74 insertions(+), 104 deletions(-) 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 58e785716be..ec27e93a529 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt @@ -24,8 +24,7 @@ class PreferredOrganizationFacade( val user = authenticationFacade.authenticatedUser val preferences = userPreferencesService.findOrCreate(user.id) var preferred = preferences.preferredOrganization - if (preferred == null || !organizationRoleService.canUserViewOrPublic(user.id, preferred.id)) { - // This GET path intentionally persists a heal of the missing or stale preference. + if (preferred == null || !organizationRoleService.canUserViewOrPublic(user, preferred.id)) { preferred = userPreferencesService.refreshPreferredOrganization(user.id) ?: return null } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt index 8edd96c55ce..af8503a74f5 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt @@ -17,7 +17,10 @@ open class PrivateOrganizationModel( val quickStart: QuickStartModel?, @get:Schema(example = "Current active subscription info") val activeCloudSubscription: PublicCloudSubscriptionModel?, - @get:Schema(example = "Whether the user views the organization purely via public-project access") + @get:Schema( + example = "false", + description = "Whether the user views the organization purely via public-project access", + ) val limitedView: Boolean, ) : RepresentationModel(), IOrganizationModel by organizationModel diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt index d779a21760c..c8cf66765fe 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt @@ -16,7 +16,6 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.data.domain.PageRequest -/** Covers the org view floor (see `OrganizationRoleService.canUserViewStrictOrPublic`) and the below-member surface scoping it enables. */ class OrganizationFloorAccessTest : AuthorizedControllerTest() { lateinit var testData: PublicProjectsControllerTestData @@ -143,6 +142,11 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { node("page.totalElements").isEqualTo(0) } + userAccount = testData.noneOnlyUser + performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(0) + } + userAccount = testData.otherOrgMember performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { node("_embedded.organizations").isArray.hasSize(1) @@ -195,7 +199,7 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { } @Test - fun `a NONE permission grants standing, reports no role and the reduced model`() { + fun `a NONE-only permission is treated as a plain floor viewer, not standing`() { executeInNewTransaction { userPreferencesService.setPreferredOrganization( organizationService.get(testData.otherOrg.id), @@ -204,12 +208,36 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { } userAccount = testData.noneOnlyUser performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("id").isEqualTo(testData.otherOrg.id) node("currentUserRole").isEqualTo(null) - node("limitedView").isEqualTo(false) + node("limitedView").isEqualTo(true) node("activeCloudSubscription").isEqualTo(null) } } + @Test + fun `a NONE-only permission does not expose the private project's languages`() { + val publicId = testData.otherOrgPublicProject.id + val privateId = testData.otherOrgPrivateProject.id + userAccount = testData.noneOnlyUser + orgLanguageTags("/languages").let { + it.assert.contains("en") + it.assert.doesNotContain("de") + } + orgLanguageTags("/languages", "&projectIds=$publicId&projectIds=$privateId").let { + it.assert.contains("en") + it.assert.doesNotContain("de") + } + } + + @Test + fun `a floor viewer sees an accessible public project's languages via the projectIds filter`() { + val publicId = testData.otherOrgPublicProject.id + userAccount = testData.nonMember + orgLanguageTags("/languages", "&projectIds=$publicId").assert.contains("en") + orgLanguageTags("/base-languages", "&projectIds=$publicId").assert.contains("en") + } + @Test fun `a floor viewer leaving the org 404s from the service, not access denial`() { userAccount = testData.nonMember @@ -221,9 +249,9 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { } @Test - fun `leave for a revoked-permission user removes the revoked tie`() { + fun `a NONE-only permission on a private-only org grants no org access`() { userAccount = testData.revokedOnlyUser - performAuthPut("/v2/organizations/${testData.noPublicOrg.id}/leave", null).andIsOk + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsNotFound executeInNewTransaction { organizationRoleService .canUserViewStrictOrPublic(testData.revokedOnlyUser.id, testData.noPublicOrg.id) @@ -233,7 +261,7 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { } @Test - fun `a revoked NONE permission on a private-only org yields the reduced model`() { + fun `a stale preference to a private-only org heals away for a NONE-only user`() { executeInNewTransaction { userPreferencesService.setPreferredOrganization( organizationService.get(testData.noPublicOrg.id), @@ -241,20 +269,13 @@ class OrganizationFloorAccessTest : AuthorizedControllerTest() { ) } userAccount = testData.revokedOnlyUser - performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { - node("id").isEqualTo(testData.noPublicOrg.id) - node("currentUserRole").isEqualTo(null) - node("limitedView").isEqualTo(false) - node("activeCloudSubscription").isEqualTo(null) - } - } - - @Test - fun `a NONE permission keeps org access and reports no role on the org endpoint`() { - userAccount = testData.revokedOnlyUser - performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsOk.andAssertThatJson { - node("currentUserRole").isEqualTo(null) - } + val response = performAuthGet("/v2/preferred-organization").andIsOk.andReturn() + val id = + jacksonObjectMapper() + .readTree(response.response.contentAsString) + .path("id") + .asLong() + id.assert.isNotEqualTo(testData.noPublicOrg.id) } @Test diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt index 60488eaf40f..beebf955e1c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt @@ -29,10 +29,7 @@ class TestDataBuilder( val data = DATA() - /** - * Raw-state fixups TestDataService runs after the graph is saved — for states the JPA layer - * self-heals or forbids on entity save (soft deletes, missing base language). - */ + /** Applied raw after save: states the JPA layer self-heals or forbids on entity save (soft deletes, missing base language). */ val afterSaveRawStates = mutableListOf<(EntityManager) -> Unit>() fun rawStateAfterSave(fn: (EntityManager) -> Unit) { diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt index 574594def25..5aa721fa099 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt @@ -2,10 +2,6 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.development.testDataBuilder.builders.ProjectBuilder -/** - * The inherited BaseTestData project ("Private project") stays non-public so the list endpoint's - * exclusion of private projects can be asserted. - */ class PublicProjectsE2eData( count: Int = 6, includeForeignOrgProject: Boolean = true, @@ -18,7 +14,6 @@ class PublicProjectsE2eData( name = "Community User" } - // a dropped organizationId filter in the org-scoped listing would surface this foreign project if (includeForeignOrgProject) { addProject(organizationOwner = communityUserBuilder.defaultOrganizationBuilder.self) { name = "Community Outsider" diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt index 24f318b5e5f..3db11ccf6ab 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt @@ -40,7 +40,7 @@ interface OrganizationRepository : JpaRepository { and r.organization = o and (r.type = :roleType or :roleType is null) left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or r is not null) + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or r is not null) and (:search is null or (lower(o.name) like lower(concat('%', cast(:search as text), '%')))) and (:exceptOrganizationId is null or (o.id <> :exceptOrganizationId)) and o.deletedAt is null """, @@ -51,7 +51,7 @@ interface OrganizationRepository : JpaRepository { and r.organization = o and (r.type = :roleType or :roleType is null) left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or r is not null) + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or r is not null) and (:search is null or (lower(o.name) like lower(concat('%', cast(:search as text), '%')))) and (:exceptOrganizationId is null or (o.id <> :exceptOrganizationId)) and o.deletedAt is null """, @@ -72,7 +72,8 @@ interface OrganizationRepository : JpaRepository { left join o.memberRoles mr on mr.user.id = :userId left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or mr is not null) and o.id <> :exceptOrganizationId and o.deletedAt is null + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or mr is not null) + and o.id <> :exceptOrganizationId and o.deletedAt is null group by mr.id, o.id, bp.id order by mr.id asc nulls last """, @@ -90,7 +91,8 @@ interface OrganizationRepository : JpaRepository { left join o.memberRoles mr on mr.user.id = :userId left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or mr is not null) and o.id = :organizationId and o.deletedAt is null + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or mr is not null) + and o.id = :organizationId and o.deletedAt is null """, ) fun canUserView( 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 90832d2009c..a7df1740a3e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -50,29 +50,22 @@ interface ProjectRepository : JpaRepository { ) """ - /** - * Expects aliases `r` (Project), `bl` (base language), `o` (owning organization). - * `o.deletedAt is null` is load-bearing: org soft-delete leaves projects undeleted and - * public, so dropping it re-opens guest access to soft-deleted organizations. - */ + /** `o.deletedAt is null` is load-bearing — dropping it re-opens guest access to soft-deleted orgs. */ const val PUBLIC_PROJECT_VISIBILITY = """ r.public = true and r.deletedAt is null and r.organizationOwner is not null and r.baseLanguage is not null and bl.deletedAt is null and o.deletedAt is null """ - /** - * A project a below-member viewer may read: public, or held via any permission row. - * - * `r.deletedAt is null and o.deletedAt is null` are top-level so they also guard the permission-row - * branch, which [PUBLIC_PROJECT_VISIBILITY] does not — do not "deduplicate" them against the public - * branch, or that branch re-opens to soft-deleted projects/orgs. Splice consumers must join `r`/`bl`/`o`. - */ + /** `r.deletedAt`/`o.deletedAt` sit top-level so they also guard the permission branch, which [PUBLIC_PROJECT_VISIBILITY] does not. */ const val BELOW_MEMBER_ACCESSIBLE_PROJECT = """ ( r.deletedAt is null and o.deletedAt is null and ( ($PUBLIC_PROJECT_VISIBILITY) - or exists (select perm.id from Permission perm where perm.user.id = :userId and perm.project = r) + or exists ( + select perm.id from Permission perm + where perm.user.id = :userId and perm.project = r and (perm.type <> 'NONE' or perm.type is null) + ) ) ) """ @@ -161,7 +154,6 @@ interface ProjectRepository : JpaRepository { ) fun hasPublicProjects(organizationId: Long): Boolean - /** Ids of the org's projects a below-member viewer may read (see [BELOW_MEMBER_ACCESSIBLE_PROJECT]). */ @Query( """select r.id from Project r left join r.baseLanguage bl 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 1d232d650f8..bef5f81c974 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 @@ -47,10 +47,6 @@ class OrganizationRoleService( private val self: OrganizationRoleService, private val cacheManager: CacheManager, ) { - /** - * Strict = no public-project floor; do not substitute the `…OrPublic` variant, which silently - * over-grants org visibility to any authenticated user on a public-project org. - */ fun canUserViewStrict( userId: Long, organizationId: Long, @@ -81,12 +77,6 @@ class OrganizationRoleService( organizationId: Long, ): Boolean = user.isSupporterOrAdmin() || hasAnyOrganizationRole(user.id, organizationId) - /** - * The org view floor with no `isSupporterOrAdmin()` short-circuit, on purpose — - * [io.tolgee.security.authorization.OrganizationAuthorizationInterceptor] calls this variant so - * supporter/admin access instead routes through its `canBypass` (see there). Do not add an admin - * short-circuit or swap in [canUserViewOrPublic]. - */ fun canUserViewStrictOrPublic( userId: Long, organizationId: Long, 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 e4384bfd716..a84adc32100 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 @@ -70,10 +70,6 @@ class SecurityService( @set:Lazy lateinit var projectService: ProjectService - /** - * `null` in and out means "no filter" — a below-member viewer never gets `null` back, so callers - * must pass the result on to a query that scopes by it rather than treating `null` as "all". - */ fun getAccessibleProjectIdsInOrganization( organizationId: Long, requestedProjectIds: List?, @@ -82,8 +78,10 @@ class SecurityService( if (organizationRoleService.canUserViewAtLeastMember(user, organizationId)) { return requestedProjectIds } - val accessible = projectService.getBelowMemberAccessibleProjectIds(organizationId, user.id).toSet() - return requestedProjectIds?.filter { it in accessible } ?: accessible.toList() + val accessibleIds = projectService.getBelowMemberAccessibleProjectIds(organizationId, user.id) + if (requestedProjectIds == null) return accessibleIds + val accessibleSet = accessibleIds.toSet() + return requestedProjectIds.filter { it in accessibleSet } } fun checkAnyProjectPermission(projectId: Long) { 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 6ba812e78ca..3381068a034 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 @@ -72,6 +72,7 @@ class OrganizationAuthorizationInterceptor( requiredRole ?: "read-only", ) + // raw floor, not canUserViewOrPublic: admin/supporter access must fall through to canBypass below to stay audit-logged if (!organizationRoleService.canUserViewStrictOrPublic(userId, organization.id)) { if (!canBypass(request, handler)) { logger.debug( 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 594a8021f4f..4980b9c7b52 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 @@ -180,9 +180,6 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `the interceptor consults the raw floor, never the admin-aware variant`() { - // Guards the raw-vs-admin split: swapping the interceptor to the admin-aware canUserViewOrPublic - // would fold in isSupporterOrAdmin() and suppress the admin-privileges audit log. The mocked - // outcome cannot catch that, so pin the method identity instead. Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(true) mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt index 4adfc833558..80494414f91 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt @@ -16,13 +16,8 @@ import org.springframework.stereotype.Repository @Lazy interface GlossaryRepository : JpaRepository { companion object { - /** - * The canonical [io.tolgee.repository.ProjectRepository.BELOW_MEMBER_ACCESSIBLE_PROJECT]; consuming - * queries must join its `r` (assigned project) / `bl` (base language) / `o` (org) aliases + `:userId`. - */ const val ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE = ProjectRepository.BELOW_MEMBER_ACCESSIBLE_PROJECT - /** A glossary a below-member reader may read: has at least one [ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE] project. Expects alias `g`. */ const val BELOW_MEMBER_ACCESSIBLE = """ exists ( select r.id from Glossary g2 @@ -122,10 +117,6 @@ interface GlossaryRepository : JpaRepository { search: String?, ): Page - /** - * Below-member variant: joins only the projects the user can access, so private assigned projects - * leak neither into `firstAssignedProjectName` nor the count. - */ @Query( """ select g.id as id, @@ -151,16 +142,6 @@ interface GlossaryRepository : JpaRepository { search: String?, ): Page - @Query( - """ - select gp.project_id - from glossary_project gp - where gp.glossary_id = :glossaryId - """, - nativeQuery = true, - ) - fun findAssignedProjectsIdsByGlossaryId(glossaryId: Long): Set - @Query( """ select r from Glossary g diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt index 75bac159a8c..b59992113c4 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt @@ -156,10 +156,6 @@ class GlossaryService( glossaryRepository.delete(glossary) } - fun getAssignedProjectsIds(glossary: Glossary): Set { - return glossaryRepository.findAssignedProjectsIdsByGlossaryId(glossary.id) - } - fun getAssignedProjectsForCurrentUser(glossary: Glossary): Collection { if (isBelowMemberReader(glossary.organizationOwner.id)) { return glossaryRepository.findBelowMemberAccessibleAssignedProjects(glossary.id, currentUserId()) diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt index 44f19867b2a..d8f8448b796 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt @@ -46,9 +46,6 @@ class GlossaryGuestAccessTest : AuthorizedControllerTest() { @Test fun `guest lists only glossaries assigned to accessible projects`() { - // storedGuest and noneOnlyUser hold permission rows on the private project, so its glossaries - // count as accessible for them; the degenerate (no base language) public project is not - // publicly visible, so its glossary stays hidden for everyone below MEMBER assertGuestList( testData.virtualGuest, "Mixed assignment glossary", @@ -57,13 +54,11 @@ class GlossaryGuestAccessTest : AuthorizedControllerTest() { assertGuestList( testData.storedGuest, "Mixed assignment glossary", - "Private project glossary", "Public project glossary", ) assertGuestList( testData.noneOnlyUser, "Mixed assignment glossary", - "Private project glossary", "Public project glossary", ) } @@ -114,10 +109,9 @@ class GlossaryGuestAccessTest : AuthorizedControllerTest() { .andIsOk performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}") .andIsNotFound - // a permission row on the private project makes its glossary accessible userAccount = testData.storedGuest performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}") - .andIsOk + .andIsNotFound } @Test @@ -187,8 +181,6 @@ class GlossaryGuestAccessTest : AuthorizedControllerTest() { @Test fun `guest export omits a private co-assigned project's languages`() { - // mixedGlossary is assigned to a public project (en) and a private project (en, de); a floor - // viewer must not receive the private-only "de" column, while a member does. userAccount = testData.virtualGuest exportLanguageHeaders(testData.mixedGlossary.id).assert.doesNotContain("de") diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 7f2e2857edf..e04b695b952 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -5192,7 +5192,10 @@ export interface components { )[]; /** Format: int64 */ id: number; - /** @example false */ + /** + * @description Whether the user views the organization purely via public-project access + * @example false + */ limitedView: boolean; /** @example Beautiful organization */ name: string; diff --git a/webapp/src/views/organizations/OrganizationProfileView.tsx b/webapp/src/views/organizations/OrganizationProfileView.tsx index b97ccf954d4..0601ba12385 100644 --- a/webapp/src/views/organizations/OrganizationProfileView.tsx +++ b/webapp/src/views/organizations/OrganizationProfileView.tsx @@ -19,6 +19,7 @@ import { OrganizationFields } from './components/OrganizationFields'; import { OrganizationProfileAvatar } from './OrganizationProfileAvatar'; import { useLeaveOrganization } from './useLeaveOrganization'; import { useIsAdmin } from 'tg.globalContext/helpers'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; type OrganizationBody = components['schemas']['OrganizationDto']; @@ -54,7 +55,7 @@ export const OrganizationProfileView: FunctionComponent< const isAdmin = useIsAdmin(); const readOnly = organization.data?.currentUserRole !== 'OWNER' && !isAdmin; - const hasNoRole = !organization.data?.currentUserRole; + const hasNoRole = !isAtLeastMemberOrgRole(organization.data?.currentUserRole); const onSubmit = (values: OrganizationBody) => { const toSave = { diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index 523283759a0..54d4151ae48 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -19,6 +19,7 @@ import { OrganizationSwitch } from 'tg.component/organizationSwitch/Organization import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CriticalUsageCircle } from 'tg.ee'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; export const ProjectListView = () => { const [page, setPage] = useState(0); @@ -50,7 +51,8 @@ export const ProjectListView = () => { const isAdminOrSupporter = useIsAdminOrSupporter(); const isAdminAccess = - !preferredOrganization?.currentUserRole && isAdminOrSupporter; + !isAtLeastMemberOrgRole(preferredOrganization?.currentUserRole) && + isAdminOrSupporter; const addAllowed = isOrganizationOwnerOrMaintainer || isAdminAccess;