Skip to content

Commit f2e6935

Browse files
committed
feat: deregister native apps and grant them to all organizations at once
Two gaps in the server-administration screen for native apps. An admin could grant an app organization by organization, but had no way to say "every organization". Doing that by inserting a row per organization would have silently missed every organization created afterwards, so this is a flag on the install instead: `availableToAllOrganizations` covers current and future organizations alike. The flag and the explicit grants stay independent. Turning the flag on leaves the per-organization grants untouched, so turning it off falls back to exactly those — and that fallback disables the app only in projects that were covered solely by the blanket grant. For the same reason, revoking a single organization while the flag is on no longer disables that organization's projects: they are still covered. The second gap: an app could be registered but never removed. Deregistering a native install now deletes its organization availability, its enablement in every project, and the install itself, after which its client credentials stop working. Both operations act on native installs only — an organization-owned install returns 404, so an admin cannot reach into an organization's own app from here — and both require the admin role, leaving supporters a read-only screen.
1 parent 5e9e6c2 commit f2e6935

18 files changed

Lines changed: 3332 additions & 5600 deletions

File tree

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/administration/AdministrationAppsController.kt

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ class AdministrationAppsController(
6565
return pagedAppInstallResourcesAssembler.toModel(installs, appInstallModelAssembler)
6666
}
6767

68+
@DeleteMapping("/{installId}")
69+
@Operation(
70+
summary = "Deregister a native app",
71+
description =
72+
"Removes the native app from the server: its availability for every organization, its " +
73+
"enablement in every project and the install itself. Its client credentials stop working.",
74+
)
75+
@RequiresSuperAuthentication
76+
fun deregister(
77+
@PathVariable installId: Long,
78+
) {
79+
appInstallService.remove(organizationId = null, installId = installId)
80+
}
81+
6882
@GetMapping("/{installId}/organizations")
6983
@Operation(
7084
summary = "List organizations the app is available to",
@@ -79,6 +93,35 @@ class AdministrationAppsController(
7993
return appAvailableOrganizationModelAssembler.toCollectionModel(organizations)
8094
}
8195

96+
@PutMapping("/{installId}/organizations/all")
97+
@Operation(
98+
summary = "Make the app available to all organizations",
99+
description =
100+
"Allows every organization — including organizations created later — to enable this native " +
101+
"app. Explicit per-organization grants are kept, so revoking this falls back to them. " +
102+
"Idempotent.",
103+
)
104+
@RequiresSuperAuthentication
105+
fun grantToAllOrganizations(
106+
@PathVariable installId: Long,
107+
) {
108+
appAvailabilityService.grantToAllOrganizations(installId)
109+
}
110+
111+
@DeleteMapping("/{installId}/organizations/all")
112+
@Operation(
113+
summary = "Revoke the app's availability for all organizations",
114+
description =
115+
"Drops the blanket availability and disables the app in every project whose organization has " +
116+
"no explicit grant. Idempotent.",
117+
)
118+
@RequiresSuperAuthentication
119+
fun revokeFromAllOrganizations(
120+
@PathVariable installId: Long,
121+
) {
122+
appAvailabilityService.revokeFromAllOrganizations(installId)
123+
}
124+
82125
@PutMapping("/{installId}/organizations/{organizationId}")
83126
@Operation(
84127
summary = "Make the app available to an organization",
@@ -100,7 +143,8 @@ class AdministrationAppsController(
100143
@Operation(
101144
summary = "Revoke the app's availability for an organization",
102145
description =
103-
"Revokes availability and disables the app in every project of that organization. " +
146+
"Revokes the explicit grant and disables the app in every project of that organization — " +
147+
"unless the app is available to all organizations, which keeps covering it. " +
104148
"Idempotent — no-op when it was not available.",
105149
)
106150
@RequiresSuperAuthentication

backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModel.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ open class AppInstallModel(
1616
val scopes: List<String>,
1717
val clientId: String?,
1818
val clientSecretPrefix: String?,
19+
/**
20+
* Native apps only: whether every organization — including ones created later — may enable this
21+
* app, on top of the explicitly granted ones.
22+
*/
23+
val availableToAllOrganizations: Boolean = false,
1924
/**
2025
* The OAuth client secret in plaintext. Present only in the response to registration — it is never
2126
* stored and cannot be retrieved again.

backend/api/src/main/kotlin/io/tolgee/hateoas/organization/apps/AppInstallModelAssembler.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class AppInstallModelAssembler(
4242
scopes = entity.grantedScopes.map { it.value },
4343
clientId = entity.clientId,
4444
clientSecretPrefix = entity.clientSecretPrefix,
45+
availableToAllOrganizations = entity.availableToAllOrganizations,
4546
clientSecret = plaintextClientSecret,
4647
)
4748
}

backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/apps/NativeAppsControllerTest.kt

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.tolgee.api.v2.controllers.apps
22

3+
import io.tolgee.development.testDataBuilder.data.LateAppsOrganizationTestData
34
import io.tolgee.development.testDataBuilder.data.NativeAppsTestData
45
import io.tolgee.fixtures.andAssertThatJson
56
import io.tolgee.fixtures.andIsBadRequest
@@ -37,6 +38,8 @@ class NativeAppsControllerTest : AuthorizedControllerTest() {
3738

3839
lateinit var testData: NativeAppsTestData
3940

41+
var lateTestData: LateAppsOrganizationTestData? = null
42+
4043
@BeforeEach
4144
fun setup() {
4245
testData = NativeAppsTestData()
@@ -48,6 +51,8 @@ class NativeAppsControllerTest : AuthorizedControllerTest() {
4851
@AfterEach
4952
fun cleanup() {
5053
AppsTestFixtures.removeNativeInstalls(appInstallService)
54+
lateTestData?.let { testDataService.cleanTestData(it.root) }
55+
lateTestData = null
5156
testDataService.cleanTestData(testData.root)
5257
}
5358

@@ -138,6 +143,9 @@ class NativeAppsControllerTest : AuthorizedControllerTest() {
138143
performAuthGet("${appsUrl(install)}/organizations").andIsForbidden
139144
performAuthPut(organizationUrl(install), null).andIsForbidden
140145
performAuthDelete(organizationUrl(install)).andIsForbidden
146+
performAuthPut(allOrganizationsUrl(install), null).andIsForbidden
147+
performAuthDelete(allOrganizationsUrl(install)).andIsForbidden
148+
performAuthDelete(appsUrl(install)).andIsForbidden
141149
}
142150

143151
@Test
@@ -232,6 +240,168 @@ class NativeAppsControllerTest : AuthorizedControllerTest() {
232240
appAvailabilityService.listNativeInstallsForOrganization(testData.organization.id).assert.isEmpty()
233241
}
234242

243+
@Test
244+
fun `a project can enable a native app made available to all organizations without an explicit grant`() {
245+
val install = createNativeInstall()
246+
247+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
248+
249+
appAvailabilityService.listOrganizations(install.id).assert.isEmpty()
250+
userAccount = testData.user
251+
performAuthPut("${projectAppsUrl()}/${install.id}", null).andIsOk.andAssertThatJson {
252+
node("appId").isEqualTo("test-app")
253+
node("enabled").isEqualTo(true)
254+
}
255+
appEnablementService.isEnabledForProject(testData.project.id, install.id).assert.isTrue()
256+
}
257+
258+
@Test
259+
fun `the project listing shows a native app made available to all organizations`() {
260+
val install = createNativeInstall()
261+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
262+
userAccount = testData.user
263+
264+
performAuthGet(projectAppsUrl()).andIsOk.andAssertThatJson {
265+
node("_embedded.projectApps").isArray.hasSize(1)
266+
node("_embedded.projectApps[0].id").isEqualTo(install.id)
267+
}
268+
}
269+
270+
@Test
271+
fun `the admin listing exposes the all-organizations flag`() {
272+
val install = createNativeInstall()
273+
274+
performAuthGet("/v2/administration/apps").andIsOk.andAssertThatJson {
275+
node("_embedded.appInstalls[0].availableToAllOrganizations").isEqualTo(false)
276+
}
277+
278+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
279+
280+
performAuthGet("/v2/administration/apps").andIsOk.andAssertThatJson {
281+
node("_embedded.appInstalls[0].availableToAllOrganizations").isEqualTo(true)
282+
}
283+
}
284+
285+
@Test
286+
fun `an organization created after the flag was set is covered as well`() {
287+
val install = createNativeInstall()
288+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
289+
290+
lateTestData = LateAppsOrganizationTestData()
291+
testDataService.saveTestData(lateTestData!!.root)
292+
val late = lateTestData!!
293+
294+
val available = appAvailabilityService.listNativeInstallsForOrganization(late.organization.id)
295+
available.map { it.id }.assert.containsExactly(install.id)
296+
userAccount = late.user
297+
performAuthPut("/v2/projects/${late.project.id}/apps/${install.id}", null).andIsOk
298+
appEnablementService.isEnabledForProject(late.project.id, install.id).assert.isTrue()
299+
}
300+
301+
@Test
302+
fun `granting to all organizations is idempotent and keeps explicit grants intact`() {
303+
val install = createNativeInstall()
304+
grantAvailability(install)
305+
306+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
307+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
308+
309+
appAvailabilityService.listOrganizations(install.id).assert.hasSize(1)
310+
nativeInstall(install).availableToAllOrganizations.assert.isTrue()
311+
}
312+
313+
@Test
314+
fun `revoking from all organizations is idempotent`() {
315+
val install = createNativeInstall()
316+
317+
performAuthDelete(allOrganizationsUrl(install)).andIsOk
318+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
319+
performAuthDelete(allOrganizationsUrl(install)).andIsOk
320+
performAuthDelete(allOrganizationsUrl(install)).andIsOk
321+
322+
nativeInstall(install).availableToAllOrganizations.assert.isFalse()
323+
}
324+
325+
@Test
326+
fun `revoking from all organizations disables the app only where it was not explicitly granted`() {
327+
val install = createNativeInstall()
328+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
329+
grantAvailability(install, testData.otherOrganization.id)
330+
userAccount = testData.user
331+
performAuthPut("${projectAppsUrl()}/${install.id}", null).andIsOk
332+
userAccount = testData.otherOwner
333+
performAuthPut("/v2/projects/${testData.otherProject.id}/apps/${install.id}", null).andIsOk
334+
335+
userAccount = testData.admin
336+
performAuthDelete(allOrganizationsUrl(install)).andIsOk
337+
338+
appEnablementService.isEnabledForProject(testData.project.id, install.id).assert.isFalse()
339+
appEnablementService.isEnabledForProject(testData.otherProject.id, install.id).assert.isTrue()
340+
}
341+
342+
@Test
343+
fun `granting and revoking a single organization leaves the all-organizations flag alone`() {
344+
val install = createNativeInstall()
345+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
346+
347+
performAuthPut(organizationUrl(install), null).andIsOk
348+
nativeInstall(install).availableToAllOrganizations.assert.isTrue()
349+
userAccount = testData.user
350+
performAuthPut("${projectAppsUrl()}/${install.id}", null).andIsOk
351+
352+
userAccount = testData.admin
353+
performAuthDelete(organizationUrl(install)).andIsOk
354+
355+
nativeInstall(install).availableToAllOrganizations.assert.isTrue()
356+
appAvailabilityService.listOrganizations(install.id).assert.isEmpty()
357+
appEnablementService.isEnabledForProject(testData.project.id, install.id).assert.isTrue()
358+
}
359+
360+
@Test
361+
fun `deregistering a native app removes it everywhere`() {
362+
val install = createNativeInstall()
363+
performAuthPut(allOrganizationsUrl(install), null).andIsOk
364+
grantAvailability(install, testData.otherOrganization.id)
365+
userAccount = testData.user
366+
performAuthPut("${projectAppsUrl()}/${install.id}", null).andIsOk
367+
userAccount = testData.otherOwner
368+
performAuthPut("/v2/projects/${testData.otherProject.id}/apps/${install.id}", null).andIsOk
369+
370+
userAccount = testData.admin
371+
performAuthDelete(appsUrl(install)).andIsOk
372+
373+
AppsTestFixtures.nativeInstalls(appInstallService).assert.isEmpty()
374+
appAvailabilityService.listOrganizations(install.id).assert.isEmpty()
375+
appEnablementService.isEnabledForProject(testData.project.id, install.id).assert.isFalse()
376+
appEnablementService.isEnabledForProject(testData.otherProject.id, install.id).assert.isFalse()
377+
}
378+
379+
@Test
380+
fun `the all-organizations and deregister endpoints reject an organization-owned install`() {
381+
val install = registerOrganizationApp()
382+
383+
performAuthPut(allOrganizationsUrl(install), null).andIsNotFound
384+
performAuthDelete(allOrganizationsUrl(install)).andIsNotFound
385+
performAuthDelete(appsUrl(install)).andIsNotFound
386+
387+
appInstallService.find(testData.organization.id, install.id).assert.isNotNull
388+
}
389+
390+
@Test
391+
fun `the all-organizations and deregister endpoints reject a supporter`() {
392+
val install = createNativeInstall()
393+
userAccount = testData.supporter
394+
395+
performAuthPut(allOrganizationsUrl(install), null).andIsForbidden
396+
performAuthDelete(allOrganizationsUrl(install)).andIsForbidden
397+
performAuthDelete(appsUrl(install)).andIsForbidden
398+
399+
userAccount = testData.admin
400+
nativeInstall(install).availableToAllOrganizations.assert.isFalse()
401+
}
402+
403+
private fun nativeInstall(install: AppInstall) = appInstallService.getNative(install.id)
404+
235405
private fun createNativeInstall(): AppInstall {
236406
AppsTestFixtures.mockManifest(appManifestHttpClient)
237407
return appInstallService
@@ -270,6 +440,8 @@ class NativeAppsControllerTest : AuthorizedControllerTest() {
270440
organizationId: Long = testData.organization.id,
271441
) = "${appsUrl(install)}/organizations/$organizationId"
272442

443+
private fun allOrganizationsUrl(install: AppInstall) = "${appsUrl(install)}/organizations/all"
444+
273445
private fun projectAppsUrl() = "/v2/projects/${testData.project.id}/apps"
274446

275447
companion object {

backend/app/src/test/kotlin/io/tolgee/service/apps/AppsDeletionTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.tolgee.service.apps
22

33
import io.tolgee.AbstractSpringTest
44
import io.tolgee.development.testDataBuilder.data.AppsTestData
5+
import io.tolgee.model.Project
56
import io.tolgee.service.project.ProjectHardDeletingService
67
import io.tolgee.testing.assert
78
import io.tolgee.util.executeInNewTransaction
@@ -85,6 +86,62 @@ class AppsDeletionTest : AbstractSpringTest() {
8586
appAvailabilityService.listOrganizations(installId).assert.isEmpty()
8687
}
8788

89+
@Test
90+
fun `removing a native install clears its enablements across every organization`() {
91+
val installId = registerNativeInstall()
92+
appAvailabilityService.grantToAllOrganizations(installId)
93+
enableForProject(testData.projectBuilder.self.id, installId)
94+
enableForProject(testData.otherProject.id, installId)
95+
96+
appInstallService.remove(organizationId = null, installId = installId)
97+
98+
appEnablementService.isEnabledForProject(testData.projectBuilder.self.id, installId).assert.isFalse()
99+
appEnablementService.isEnabledForProject(testData.otherProject.id, installId).assert.isFalse()
100+
AppsTestFixtures.nativeInstalls(appInstallService).assert.isEmpty()
101+
}
102+
103+
@Test
104+
fun `revoking the blanket availability keeps enablements of explicitly granted organizations`() {
105+
val installId = registerNativeInstall()
106+
appAvailabilityService.grantToAllOrganizations(installId)
107+
appAvailabilityService.grant(
108+
installId = installId,
109+
organizationId = testData.otherOrganization.id,
110+
author = testData.user,
111+
)
112+
enableForProject(testData.projectBuilder.self.id, installId)
113+
enableForProject(testData.otherProject.id, installId)
114+
115+
appAvailabilityService.revokeFromAllOrganizations(installId)
116+
117+
appEnablementService.isEnabledForProject(testData.projectBuilder.self.id, installId).assert.isFalse()
118+
appEnablementService.isEnabledForProject(testData.otherProject.id, installId).assert.isTrue()
119+
}
120+
121+
private fun registerNativeInstall(): Long {
122+
return executeInNewTransaction(platformTransactionManager) {
123+
appInstallService
124+
.selfRegister(
125+
organization = null,
126+
manifestUrl = AppsTestFixtures.MANIFEST_URL,
127+
author = testData.user,
128+
).install.id
129+
}
130+
}
131+
132+
private fun enableForProject(
133+
projectId: Long,
134+
installId: Long,
135+
) {
136+
executeInNewTransaction(platformTransactionManager) {
137+
appEnablementService.enable(
138+
project = entityManager.find(Project::class.java, projectId),
139+
installId = installId,
140+
author = testData.user,
141+
)
142+
}
143+
}
144+
88145
private fun deleteRuleOf(constraintName: String): String {
89146
return executeInNewTransaction(platformTransactionManager) {
90147
entityManager
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.tolgee.development.testDataBuilder.data
2+
3+
import io.tolgee.model.Organization
4+
5+
/**
6+
* A second, independent organization with a project, saved after the app under test is already
7+
* configured — so it stands for an organization that did not exist at configuration time.
8+
*/
9+
class LateAppsOrganizationTestData : BaseTestData("apps-test-late-owner@test.com", "late_apps_project") {
10+
val organization: Organization
11+
get() = userAccountBuilder.defaultOrganizationBuilder.self
12+
}

0 commit comments

Comments
 (0)