Skip to content

Commit 64d20ea

Browse files
committed
feat: add sso config feature flag (#16961)
1 parent 613d5d5 commit 64d20ea

File tree

7 files changed

+156
-5
lines changed

7 files changed

+156
-5
lines changed

airbyte-api/server-api/src/main/openapi/config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20415,7 +20415,7 @@ components:
2041520415
properties:
2041620416
organizationId:
2041720417
type: string
20418-
format: UUID
20418+
format: uuid
2041920419
companyIdentifier:
2042020420
type: string
2042120421
description: Used to name the keycloak realm
@@ -20437,7 +20437,7 @@ components:
2043720437
properties:
2043820438
organizationId:
2043920439
type: string
20440-
format: UUID
20440+
format: uuid
2044120441
companyIdentifier:
2044220442
type: string
2044320443
description: Matches the keycloak realm to be removed

airbyte-commons-entitlements/src/main/kotlin/io/airbyte/commons/entitlements/EntitlementProvider.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import io.airbyte.commons.license.annotation.RequiresAirbyteProEnabled
99
import io.airbyte.config.ActorType
1010
import io.airbyte.featureflag.AllowConfigTemplateEndpoints
1111
import io.airbyte.featureflag.DestinationDefinition
12+
import io.airbyte.featureflag.EnableSsoConfigUpdate
1213
import io.airbyte.featureflag.FeatureFlagClient
1314
import io.airbyte.featureflag.LicenseAllowDestinationObjectStorageConfig
1415
import io.airbyte.featureflag.LicenseAllowEnterpriseConnector
@@ -31,6 +32,8 @@ interface EntitlementProvider {
3132
fun hasConfigTemplateEntitlements(organizationId: UUID): Boolean
3233

3334
fun hasDestinationObjectStorageEntitlement(organizationId: UUID): Boolean
35+
36+
fun hasSsoConfigUpdateEntitlement(organizationId: UUID): Boolean
3437
}
3538

3639
/**
@@ -47,6 +50,8 @@ class DefaultEntitlementProvider : EntitlementProvider {
4750
override fun hasConfigTemplateEntitlements(organizationId: UUID): Boolean = false
4851

4952
override fun hasDestinationObjectStorageEntitlement(organizationId: UUID): Boolean = false
53+
54+
override fun hasSsoConfigUpdateEntitlement(organizationId: UUID): Boolean = false
5055
}
5156

5257
/**
@@ -75,6 +80,8 @@ class EnterpriseEntitlementProvider(
7580
override fun hasConfigTemplateEntitlements(organizationId: UUID): Boolean = activeLicense.license?.isEmbedded ?: false
7681

7782
override fun hasDestinationObjectStorageEntitlement(organizationId: UUID): Boolean = true
83+
84+
override fun hasSsoConfigUpdateEntitlement(organizationId: UUID): Boolean = false
7885
}
7986

8087
/**
@@ -114,4 +121,7 @@ class CloudEntitlementProvider(
114121

115122
override fun hasDestinationObjectStorageEntitlement(organizationId: UUID): Boolean =
116123
featureFlagClient.boolVariation(flag = LicenseAllowDestinationObjectStorageConfig, Organization(organizationId))
124+
125+
override fun hasSsoConfigUpdateEntitlement(organizationId: UUID): Boolean =
126+
featureFlagClient.boolVariation(EnableSsoConfigUpdate, Organization(organizationId))
117127
}

airbyte-commons-entitlements/src/main/kotlin/io/airbyte/commons/entitlements/EntitlementService.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import io.airbyte.commons.entitlements.models.ConnectorEntitlement
1010
import io.airbyte.commons.entitlements.models.DestinationObjectStorageEntitlement
1111
import io.airbyte.commons.entitlements.models.Entitlement
1212
import io.airbyte.commons.entitlements.models.EntitlementResult
13+
import io.airbyte.commons.entitlements.models.SsoConfigUpdateEntitlement
1314
import io.airbyte.config.ActorType
1415
import jakarta.inject.Singleton
1516
import java.util.UUID
@@ -26,6 +27,7 @@ class EntitlementService(
2627
when (entitlement) {
2728
// TODO: Remove once we've migrated the entitlement to Stigg
2829
DestinationObjectStorageEntitlement -> hasDestinationObjectStorageEntitlement(organizationId)
30+
SsoConfigUpdateEntitlement -> hasSsoConfigUpdateEntitlement(organizationId)
2931
else -> entitlementClient.checkEntitlement(organizationId, entitlement)
3032
}
3133

@@ -68,5 +70,11 @@ class EntitlementService(
6870
featureId = DestinationObjectStorageEntitlement.featureId,
6971
)
7072

73+
private fun hasSsoConfigUpdateEntitlement(organizationId: UUID): EntitlementResult =
74+
EntitlementResult(
75+
isEntitled = entitlementProvider.hasSsoConfigUpdateEntitlement(organizationId),
76+
featureId = SsoConfigUpdateEntitlement.featureId,
77+
)
78+
7179
internal fun hasConfigTemplateEntitlements(organizationId: UUID): Boolean = entitlementProvider.hasConfigTemplateEntitlements(organizationId)
7280
}

airbyte-commons-entitlements/src/main/kotlin/io/airbyte/commons/entitlements/models/EntitlementDefinitions.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ object DestinationObjectStorageEntitlement : FeatureEntitlement(
1616
featureId = "feature-destination-object-storage",
1717
)
1818

19+
object SsoConfigUpdateEntitlement : FeatureEntitlement(
20+
featureId = "feature-platform-sso-config-update",
21+
)
22+
1923
object Entitlements {
2024
private val ALL =
2125
listOf(
2226
PlatformLlmSyncJobFailureExplanation,
2327
PlatformSubOneHourSyncFrequency,
2428
DestinationObjectStorageEntitlement,
29+
SsoConfigUpdateEntitlement,
2530
)
2631

2732
private val byId = ALL.associateBy { it.featureId }

airbyte-featureflag/src/main/kotlin/FlagDefinitions.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,6 @@ object EnableDestinationCatalogValidation : Temporary<Boolean>(key = "platform.e
217217
object LicenseAllowDestinationObjectStorageConfig : Permanent<Boolean>(key = "license.allow-destination-object-storage-config", default = false)
218218

219219
object UseSonarServer : Temporary<Boolean>(key = "embedded.useSonarServer", default = false)
220+
221+
// this uses the webapp naming conventions, as the flag is used in both the frontend and platform.
222+
object EnableSsoConfigUpdate : Permanent<Boolean>(key = "featureService.ALLOW_UPDATE_SSO_CONFIG", default = false)

airbyte-server/src/main/kotlin/io/airbyte/server/apis/controllers/SSOConfigApiController.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import io.airbyte.api.server.generated.models.UpdateSSOCredentialsRequestBody
1313
import io.airbyte.commons.annotation.AuditLogging
1414
import io.airbyte.commons.annotation.AuditLoggingProvider
1515
import io.airbyte.commons.auth.roles.AuthRoleConstants
16+
import io.airbyte.commons.entitlements.EntitlementService
17+
import io.airbyte.commons.entitlements.models.SsoConfigUpdateEntitlement
1618
import io.airbyte.commons.server.scheduling.AirbyteTaskExecutors
1719
import io.airbyte.domain.models.SsoConfig
1820
import io.airbyte.domain.models.SsoKeycloakIdpCredentials
@@ -21,15 +23,17 @@ import io.airbyte.server.apis.execute
2123
import io.micronaut.http.annotation.Controller
2224
import io.micronaut.scheduling.annotation.ExecuteOn
2325
import io.micronaut.security.annotation.Secured
24-
import java.util.UUID
2526

2627
@Controller
2728
open class SSOConfigApiController(
2829
private val ssoConfigDomainService: SsoConfigDomainService,
30+
private val entitlementService: EntitlementService,
2931
) : SsoConfigApi {
3032
@Secured(AuthRoleConstants.ORGANIZATION_ADMIN)
3133
@ExecuteOn(AirbyteTaskExecutors.IO)
3234
override fun getSsoConfig(getSSOConfigRequestBody: GetSSOConfigRequestBody): SSOConfigRead {
35+
entitlementService.ensureEntitled(getSSOConfigRequestBody.organizationId, SsoConfigUpdateEntitlement)
36+
3337
val ssoConfig = ssoConfigDomainService.retrieveSsoConfig(getSSOConfigRequestBody.organizationId)
3438
return SSOConfigRead(
3539
organizationId = getSSOConfigRequestBody.organizationId,
@@ -44,10 +48,12 @@ open class SSOConfigApiController(
4448
@ExecuteOn(AirbyteTaskExecutors.IO)
4549
@AuditLogging(provider = AuditLoggingProvider.BASIC)
4650
override fun createSsoConfig(createSSOConfigRequestBody: CreateSSOConfigRequestBody) {
51+
entitlementService.ensureEntitled(createSSOConfigRequestBody.organizationId, SsoConfigUpdateEntitlement)
52+
4753
execute<Any?> {
4854
ssoConfigDomainService.createAndStoreSsoConfig(
4955
SsoConfig(
50-
UUID.fromString(createSSOConfigRequestBody.organizationId),
56+
createSSOConfigRequestBody.organizationId,
5157
createSSOConfigRequestBody.companyIdentifier,
5258
createSSOConfigRequestBody.clientId,
5359
createSSOConfigRequestBody.clientSecret,
@@ -63,9 +69,11 @@ open class SSOConfigApiController(
6369
@ExecuteOn(AirbyteTaskExecutors.IO)
6470
@AuditLogging(provider = AuditLoggingProvider.BASIC)
6571
override fun deleteSsoConfig(deleteSSOConfigRequestBody: DeleteSSOConfigRequestBody) {
72+
entitlementService.ensureEntitled(deleteSSOConfigRequestBody.organizationId, SsoConfigUpdateEntitlement)
73+
6674
execute<Any?> {
6775
ssoConfigDomainService.deleteSsoConfig(
68-
UUID.fromString(deleteSSOConfigRequestBody.organizationId),
76+
deleteSSOConfigRequestBody.organizationId,
6977
deleteSSOConfigRequestBody.companyIdentifier,
7078
)
7179
}
@@ -75,6 +83,8 @@ open class SSOConfigApiController(
7583
@ExecuteOn(AirbyteTaskExecutors.IO)
7684
@AuditLogging(provider = AuditLoggingProvider.BASIC)
7785
override fun updateSsoCredentials(updateSSOCredentialsRequestBody: UpdateSSOCredentialsRequestBody) {
86+
entitlementService.ensureEntitled(updateSSOCredentialsRequestBody.organizationId, SsoConfigUpdateEntitlement)
87+
7888
execute<Any?> {
7989
ssoConfigDomainService.updateClientCredentials(
8090
SsoKeycloakIdpCredentials(
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright (c) 2020-2025 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.server.apis.controllers
6+
7+
import io.airbyte.api.server.generated.models.CreateSSOConfigRequestBody
8+
import io.airbyte.api.server.generated.models.DeleteSSOConfigRequestBody
9+
import io.airbyte.api.server.generated.models.GetSSOConfigRequestBody
10+
import io.airbyte.api.server.generated.models.UpdateSSOCredentialsRequestBody
11+
import io.airbyte.commons.entitlements.EntitlementService
12+
import io.airbyte.commons.entitlements.models.SsoConfigUpdateEntitlement
13+
import io.airbyte.domain.models.SsoConfigRetrieval
14+
import io.airbyte.domain.services.sso.SsoConfigDomainService
15+
import io.mockk.Runs
16+
import io.mockk.every
17+
import io.mockk.just
18+
import io.mockk.mockk
19+
import io.mockk.verify
20+
import org.junit.jupiter.api.Test
21+
import java.util.UUID
22+
23+
class SsoConfigApiControllerTest {
24+
companion object {
25+
private val ssoConfigDomainService = mockk<SsoConfigDomainService>()
26+
private val entitlementService = mockk<EntitlementService>()
27+
private val ssoConfigController =
28+
SSOConfigApiController(
29+
ssoConfigDomainService,
30+
entitlementService,
31+
)
32+
}
33+
34+
@Test
35+
fun `getSsoConfig returns the config`() {
36+
val orgId = UUID.randomUUID()
37+
every { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) } just Runs
38+
every { ssoConfigDomainService.retrieveSsoConfig(orgId) } returns
39+
SsoConfigRetrieval(
40+
companyIdentifier = "id",
41+
clientId = "client-id",
42+
clientSecret = "client-secret",
43+
emailDomains = listOf("domain"),
44+
)
45+
46+
val result =
47+
ssoConfigController.getSsoConfig(
48+
GetSSOConfigRequestBody(orgId),
49+
)
50+
51+
verify(exactly = 1) { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) }
52+
53+
assert(result.organizationId == orgId)
54+
assert(result.companyIdentifier == "id")
55+
assert(result.clientId == "client-id")
56+
assert(result.clientSecret == "client-secret")
57+
assert(result.emailDomains == listOf("domain"))
58+
}
59+
60+
@Test
61+
fun `createSsoConfig creates a new config`() {
62+
val orgId = UUID.randomUUID()
63+
every { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) } just Runs
64+
every { ssoConfigDomainService.createAndStoreSsoConfig(any()) } just Runs
65+
66+
ssoConfigController.createSsoConfig(
67+
CreateSSOConfigRequestBody(
68+
organizationId = orgId,
69+
companyIdentifier = "id",
70+
clientId = "client-id",
71+
clientSecret = "client-secret",
72+
discoveryUrl = "https://www.airbyte.io",
73+
emailDomain = "domain",
74+
),
75+
)
76+
77+
verify(exactly = 1) { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) }
78+
verify(exactly = 1) { ssoConfigDomainService.createAndStoreSsoConfig(any()) }
79+
}
80+
81+
@Test
82+
fun `deleteSsoConfig removes the existing config`() {
83+
val orgId = UUID.randomUUID()
84+
every { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) } just Runs
85+
every { ssoConfigDomainService.deleteSsoConfig(orgId, any()) } just Runs
86+
87+
ssoConfigController.deleteSsoConfig(
88+
DeleteSSOConfigRequestBody(
89+
organizationId = orgId,
90+
companyIdentifier = "id",
91+
),
92+
)
93+
94+
verify(exactly = 1) { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) }
95+
verify(exactly = 1) { ssoConfigDomainService.deleteSsoConfig(orgId, any()) }
96+
}
97+
98+
@Test
99+
fun `updateSsoConfig updates a new config`() {
100+
val orgId = UUID.randomUUID()
101+
every { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) } just Runs
102+
every { ssoConfigDomainService.updateClientCredentials(any()) } just Runs
103+
104+
ssoConfigController.updateSsoCredentials(
105+
UpdateSSOCredentialsRequestBody(
106+
organizationId = orgId,
107+
clientId = "client-id",
108+
clientSecret = "client-secret",
109+
),
110+
)
111+
112+
verify(exactly = 1) { entitlementService.ensureEntitled(orgId, SsoConfigUpdateEntitlement) }
113+
verify(exactly = 1) { ssoConfigDomainService.updateClientCredentials(any()) }
114+
}
115+
}

0 commit comments

Comments
 (0)