PM-38140 Feat: SDK policy filters (#6979)

This commit is contained in:
David Perez
2026-05-27 15:59:53 -05:00
committed by GitHub
parent cc6fcecc5b
commit a0edef99e6
20 changed files with 630 additions and 103 deletions

View File

@@ -11,6 +11,9 @@ import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
@@ -134,4 +137,13 @@ interface AuthSdkSource {
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean>
/**
* Applies the appropriate filters for determining what policies apply to the user.
*/
fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>>
}

View File

@@ -15,6 +15,9 @@ import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.sdk.AuthClient
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
@@ -221,4 +224,16 @@ class AuthSdkSourceImpl(
)
}
}
override fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>> = runCatchingWithLogs {
globalClient.policies().filterByType(
policies = policies,
organizationUserPolicyContexts = organizations,
policyType = policyType,
)
}
}

View File

@@ -11,6 +11,11 @@ import timber.log.Timber
abstract class BaseSdkSource(
protected val sdkClientManager: SdkClientManager,
) {
/**
* Helper function to retrieve the global [Client] synchronously.
*/
protected val globalClient get() = sdkClientManager.globalClient
/**
* Helper function to retrieve the [Client] associated with the given [userId].
*/

View File

@@ -1,20 +1,23 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.vault.repository.util.toSdkOrganizationPolicyContext
import com.x8bit.bitwarden.data.vault.repository.util.toSdkPolicyViews
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
/**
* The default [PolicyManager] implementation. This class is responsible for
@@ -22,6 +25,8 @@ import kotlinx.coroutines.flow.mapNotNull
*/
class PolicyManagerImpl(
private val authDiskSource: AuthDiskSource,
private val authSdkSource: AuthSdkSource,
private val featureFlagManager: FeatureFlagManager,
) : PolicyManager {
@OptIn(ExperimentalCoroutinesApi::class)
override fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>> =
@@ -29,18 +34,7 @@ class PolicyManagerImpl(
.activeUserIdChangesFlow
.flatMapLatest { activeUserId ->
activeUserId
?.let { userId ->
authDiskSource
.getPoliciesFlow(userId = userId)
.map { it?.toSdkPolicyViews() }
.mapNotNull {
filterPolicies(
userId = userId,
type = type,
policies = it,
)
}
}
?.let { userId -> getAppliedPolicyViewsFlow(userId = userId, type = type) }
?: emptyFlow()
}
.distinctUntilChanged()
@@ -49,13 +43,7 @@ class PolicyManagerImpl(
authDiskSource
.userState
?.activeUserId
?.let { userId ->
filterPolicies(
userId = userId,
type = type,
policies = getPolicyViews(userId = userId),
)
}
?.let { userId -> getUserPolicies(userId = userId, type = type) }
.orEmpty()
override fun getUserPolicies(
@@ -64,9 +52,20 @@ class PolicyManagerImpl(
): List<PolicyView> =
this
.filterPolicies(
userId = userId,
type = type,
policies = getPolicyViews(userId = userId),
policies = authDiskSource
.getPolicies(userId = userId)
?.toSdkPolicyViews(),
organizations = authDiskSource
.getOrganizations(userId = userId)
?.map {
OrganizationPolicyData(
organizationUserPolicyContext = it.toSdkOrganizationPolicyContext(),
organizationShouldUsePolicies = it.permissions.shouldManagePolicies,
)
},
isPoliciesInAcceptedStateEnabled = featureFlagManager
.getFeatureFlag(key = FlagKey.PoliciesInAcceptedState),
)
.orEmpty()
@@ -77,66 +76,100 @@ class PolicyManagerImpl(
.firstOrNull()
?.organizationId
/**
* A helper method to filter policies.
*/
private fun filterPolicies(
private fun getAppliedPolicyViewsFlow(
userId: String,
type: PolicyType,
policies: List<PolicyView>?,
): List<PolicyView>? {
policies ?: return null
if (policies.isEmpty()) return emptyList()
// Get a list of the user's organizations that enforce policies.
val organizationIdsWithActivePolicies = authDiskSource
.getOrganizations(userId)
?.filter {
it.shouldUsePolicies &&
it.status >= OrganizationStatusType.ACCEPTED &&
!isOrganizationExemptFromPolicies(organization = it, policyType = type)
}
?.map { it.id }
): Flow<List<PolicyView>> = combine(
authDiskSource
.getPoliciesFlow(userId = userId)
.map { it?.toSdkPolicyViews() },
authDiskSource
.getOrganizationsFlow(userId = userId)
.map { organizations ->
organizations?.map {
OrganizationPolicyData(
organizationUserPolicyContext = it.toSdkOrganizationPolicyContext(),
organizationShouldUsePolicies = it.permissions.shouldManagePolicies,
)
}
},
featureFlagManager.getFeatureFlagFlow(key = FlagKey.PoliciesInAcceptedState),
) { policies, organizations, isEnabled ->
this
.filterPolicies(
type = type,
policies = policies,
organizations = organizations,
isPoliciesInAcceptedStateEnabled = isEnabled,
)
.orEmpty()
// Filter the policies based on the type, whether the policy is active,
// and whether the organization rules except the user from the policy.
return policies.filter {
it.type == type &&
it.enabled &&
organizationIdsWithActivePolicies.contains(it.organizationId)
}
}
private fun filterPolicies(
type: PolicyType,
policies: List<PolicyView>?,
organizations: List<OrganizationPolicyData>?,
isPoliciesInAcceptedStateEnabled: Boolean,
): List<PolicyView>? =
when {
policies == null -> null
policies.isEmpty() -> emptyList()
isPoliciesInAcceptedStateEnabled -> {
authSdkSource
.filterPolicies(
policies = policies,
policyType = type,
organizations = organizations
?.map { it.organizationUserPolicyContext }
.orEmpty(),
)
.getOrElse { emptyList() }
}
else -> {
// Legacy flow
val organizationIdsWithActivePolicies = organizations
?.filter {
@Suppress("MaxLineLength")
it.organizationUserPolicyContext.usePolicies &&
it.organizationUserPolicyContext.status >= OrganizationUserStatusType.ACCEPTED &&
!it.isOrganizationExemptFromPolicies(policyType = type)
}
?.map { it.organizationUserPolicyContext.id }
.orEmpty()
return policies.filter {
it.type == type &&
it.enabled &&
organizationIdsWithActivePolicies.contains(it.organizationId)
}
}
}
/**
* A helper method to determine if the organization is exempt from policies.
*/
private fun isOrganizationExemptFromPolicies(
organization: SyncResponseJson.Profile.Organization,
private fun OrganizationPolicyData.isOrganizationExemptFromPolicies(
policyType: PolicyType,
): Boolean =
when (policyType) {
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
organization.type == OrganizationType.OWNER
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER
}
PolicyType.PASSWORD_GENERATOR,
PolicyType.REMOVE_UNLOCK_WITH_PIN,
PolicyType.RESTRICTED_ITEM_TYPES,
-> {
false
}
-> false
else -> {
(organization.type == OrganizationType.OWNER ||
organization.type == OrganizationType.ADMIN) ||
organization.permissions.shouldManagePolicies
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER ||
this.organizationUserPolicyContext.role == OrganizationUserType.ADMIN ||
this.organizationShouldUsePolicies
}
}
private fun getPolicyViews(
userId: String,
): List<PolicyView>? = authDiskSource
.getPolicies(userId = userId)
?.toSdkPolicyViews()
}
private data class OrganizationPolicyData(
val organizationUserPolicyContext: OrganizationUserPolicyContext,
val organizationShouldUsePolicies: Boolean,
)

View File

@@ -7,6 +7,13 @@ import com.bitwarden.sdk.Client
*/
interface SdkClientManager {
/**
* Synchronously returns a [Client] that is unassociated with any user. It cannot be used for
* anything that performs a network requests. If the client is not yet ready, this will block
* until it is ready.
*/
val globalClient: Client
/**
* Returns the cached [Client] instance for the given [userId], otherwise creates and caches
* a new one and returns it.

View File

@@ -1,17 +1,24 @@
package com.x8bit.bitwarden.data.platform.manager
import android.os.Build
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.concurrentMapOf
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
/**
* Primary implementation of [SdkClientManager].
*/
class SdkClientManagerImpl(
nativeLibraryManager: NativeLibraryManager,
dispatcherManager: DispatcherManager,
sdkRepoFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
private val featureFlagManager: FeatureFlagManager,
@@ -38,7 +45,9 @@ class SdkClientManagerImpl(
}
},
) : SdkClientManager {
private val userIdToClientMap = mutableMapOf<String, Client>()
private val userIdToClientMap = concurrentMapOf<String, Client>()
private val ioScope = CoroutineScope(context = dispatcherManager.io)
private val globalClientDeferred: Deferred<Client>
init {
// The SDK requires access to Android APIs that were not made public until API 31. In order
@@ -47,8 +56,13 @@ class SdkClientManagerImpl(
if (!isBuildVersionAtLeast(Build.VERSION_CODES.S)) {
nativeLibraryManager.loadLibrary("bitwarden_uniffi")
}
// Initialize this now, so that we can access it synchronously later on.
globalClientDeferred = ioScope.async { clientProvider(null, null) }
}
override val globalClient: Client
get() = runBlocking { globalClientDeferred.await() }
override suspend fun getOrCreateClient(
userId: String,
): Client = userIdToClientMap.getOrPut(key = userId) { clientProvider(userId, null) }

View File

@@ -19,6 +19,7 @@ import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
@@ -220,11 +221,13 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideSdkClientManager(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
nativeLibraryManager: NativeLibraryManager,
sdkRepositoryFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
): SdkClientManager = SdkClientManagerImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
nativeLibraryManager = nativeLibraryManager,
sdkRepoFactory = sdkRepositoryFactory,
@@ -262,8 +265,12 @@ object PlatformManagerModule {
@Singleton
fun providePolicyManager(
authDiskSource: AuthDiskSource,
authSdkSource: AuthSdkSource,
featureFlagManager: FeatureFlagManager,
): PolicyManager = PolicyManagerImpl(
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
featureFlagManager = featureFlagManager,
)
@Provides

View File

@@ -21,6 +21,7 @@ class ScopedVaultSdkSourceImpl(
sdkPlatformApiFactory: SdkPlatformApiFactory,
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = SdkClientManagerImpl(
dispatcherManager = dispatcherManager,
// We do not want to have the real NativeLibraryManager used here to avoid
// initializing the library twice.
nativeLibraryManager = object : NativeLibraryManager {

View File

@@ -0,0 +1,48 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
/**
* Converts a list of network [SyncResponseJson.Profile.Organization] models to a list of SDK
* [OrganizationUserPolicyContext].
*/
@Suppress("MaxLineLength")
fun List<SyncResponseJson.Profile.Organization>.toSdkOrganizationPolicyContexts(): List<OrganizationUserPolicyContext> =
this.map { it.toSdkOrganizationPolicyContext() }
/**
* Converts a network [SyncResponseJson.Profile.Organization] model to an SDK
* [OrganizationUserPolicyContext].
*/
@Suppress("MaxLineLength")
fun SyncResponseJson.Profile.Organization.toSdkOrganizationPolicyContext(): OrganizationUserPolicyContext =
OrganizationUserPolicyContext(
id = this.id,
status = this.status.toSdkOrganizationUserStatusType,
role = this.type.toSdkOrganizationUserType,
enabled = this.isEnabled,
usePolicies = this.shouldUsePolicies,
isProviderUser = this.isProviderUser,
)
private val OrganizationStatusType.toSdkOrganizationUserStatusType: OrganizationUserStatusType
get() = when (this) {
OrganizationStatusType.REVOKED -> OrganizationUserStatusType.REVOKED
OrganizationStatusType.INVITED -> OrganizationUserStatusType.INVITED
OrganizationStatusType.ACCEPTED -> OrganizationUserStatusType.ACCEPTED
OrganizationStatusType.CONFIRMED -> OrganizationUserStatusType.CONFIRMED
}
private val OrganizationType.toSdkOrganizationUserType: OrganizationUserType
get() = when (this) {
OrganizationType.OWNER -> OrganizationUserType.OWNER
OrganizationType.ADMIN -> OrganizationUserType.ADMIN
OrganizationType.USER -> OrganizationUserType.USER
OrganizationType.MANAGER -> OrganizationUserType.ADMIN
OrganizationType.CUSTOM -> OrganizationUserType.CUSTOM
}

View File

@@ -16,9 +16,13 @@ import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.sdk.AuthClient
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.PlatformClient
import com.bitwarden.sdk.PoliciesClient
import com.bitwarden.sdk.RegistrationClient
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
@@ -41,11 +45,14 @@ class AuthSdkSourceTest {
private val clientPlatform = mockk<PlatformClient> {
coEvery { loadFlags(any()) } just runs
}
private val clientPolicies = mockk<PoliciesClient>()
private val client = mockk<Client> {
every { auth() } returns clientAuth
every { platform() } returns clientPlatform
every { policies() } returns clientPolicies
}
private val sdkClientManager = mockk<SdkClientManager> {
every { globalClient } returns client
coEvery { getOrCreateClient(userId = any()) } returns client
}
@@ -520,4 +527,35 @@ class AuthSdkSourceTest {
)
}
}
@Test
fun `filterPolicies should call SDK and return a Result with the correct data`() =
runBlocking {
val policies = listOf(mockk<PolicyView>())
val organizations = listOf(mockk<OrganizationUserPolicyContext>())
val policyType = mockk<PolicyType>()
val expectedResult = listOf(mockk<PolicyView>())
coEvery {
clientPolicies.filterByType(
policies = policies,
organizationUserPolicyContexts = organizations,
policyType = policyType,
)
} returns expectedResult
val result = authSkdSource.filterPolicies(
policies = policies,
organizations = organizations,
policyType = policyType,
)
assertEquals(expectedResult.asSuccess(), result)
coVerify(exactly = 1) {
clientPolicies.filterByType(
policies = policies,
organizationUserPolicyContexts = organizations,
policyType = policyType,
)
}
}
}

View File

@@ -1,14 +1,19 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import io.mockk.every
import io.mockk.mockk
@@ -17,72 +22,85 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
@Suppress("LargeClass")
class PolicyManagerTest {
private val mutableUserStateFlow = MutableStateFlow<UserStateJson?>(null)
private val mutablePolicyFlow = MutableStateFlow<List<SyncResponseJson.Policy>?>(null)
private val mutableOrganizationsFlow =
MutableStateFlow<List<SyncResponseJson.Profile.Organization>?>(null)
private val authDiskSource: AuthDiskSource = mockk {
every { userStateFlow } returns mutableUserStateFlow
every { getPoliciesFlow(USER_ID) } returns mutablePolicyFlow
every { getOrganizationsFlow(USER_ID) } returns mutableOrganizationsFlow
}
private val authSdkSource: AuthSdkSource = mockk()
private val mutablePoliciesInAcceptedStateFlagFlow = MutableStateFlow(true)
private val featureFlagManager: FeatureFlagManager = mockk {
every {
getFeatureFlagFlow(key = FlagKey.PoliciesInAcceptedState)
} returns mutablePoliciesInAcceptedStateFlagFlow
every { getFeatureFlag(key = FlagKey.PoliciesInAcceptedState) } answers {
mutablePoliciesInAcceptedStateFlagFlow.value
}
}
private lateinit var policyManager: PolicyManager
@BeforeEach
fun setUp() {
policyManager = PolicyManagerImpl(
authDiskSource = authDiskSource,
)
}
private val policyManager: PolicyManager = PolicyManagerImpl(
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
featureFlagManager = featureFlagManager,
)
@Test
fun `currentUserPoliciesListFlow should emit changes to current user's policy data`() =
fun `getActivePoliciesFlow should emit changes to current user's policy data`() =
runTest {
val userStateJson = mockk<UserStateJson> {
every { activeUserId } returns USER_ID
}
val organizationsOne = createMockOrganizationNetwork(
val organizations = createMockOrganizationNetwork(
number = 1,
isEnabled = true,
shouldUsePolicies = true,
)
val organizationsTwo = createMockOrganizationNetwork(
number = 2,
isEnabled = true,
shouldUsePolicies = true,
)
val storedPolicyOne = createMockPolicy(
isEnabled = true,
number = 1,
organizationId = organizationsOne.id,
organizationId = organizations.id,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
)
val storedPolicyTwo = createMockPolicy(
isEnabled = true,
number = 2,
organizationId = organizationsTwo.id,
organizationId = organizations.id,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
)
val expectedPolicyOne = createMockPolicyView(
enabled = true,
number = 1,
organizationId = organizationsOne.id,
organizationId = organizations.id,
type = PolicyType.MAXIMUM_VAULT_TIMEOUT,
)
val expectedPolicyTwo = createMockPolicyView(
enabled = true,
number = 2,
organizationId = organizationsTwo.id,
organizationId = organizations.id,
type = PolicyType.MAXIMUM_VAULT_TIMEOUT,
)
every {
authDiskSource.getOrganizations(USER_ID)
} returns listOf(organizationsOne) andThen listOf(organizationsTwo)
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = any(),
)
} returnsMany listOf(
listOf(expectedPolicyOne).asSuccess(),
listOf(expectedPolicyTwo).asSuccess(),
)
mutableUserStateFlow.value = userStateJson
mutableOrganizationsFlow.value = listOf(organizations)
mutablePolicyFlow.value = listOf(storedPolicyOne)
policyManager
@@ -96,11 +114,127 @@ class PolicyManagerTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `getActivePoliciesFlow when feature flag is false should emit policies using legacy filtering`() =
runTest {
mutablePoliciesInAcceptedStateFlagFlow.value = false
val userStateJson = mockk<UserStateJson> {
every { activeUserId } returns USER_ID
}
val organizations = createMockOrganizationNetwork(
number = 1,
isEnabled = true,
shouldUsePolicies = true,
)
val storedPolicyOne = createMockPolicy(
isEnabled = true,
number = 1,
organizationId = organizations.id,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
)
val expectedPolicyOne = createMockPolicyView(
enabled = true,
number = 1,
organizationId = organizations.id,
type = PolicyType.MAXIMUM_VAULT_TIMEOUT,
)
val storedPolicyTwo = createMockPolicy(
isEnabled = true,
number = 2,
organizationId = organizations.id,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
)
val expectedPolicyTwo = createMockPolicyView(
enabled = true,
number = 2,
organizationId = organizations.id,
type = PolicyType.MAXIMUM_VAULT_TIMEOUT,
)
mutableUserStateFlow.value = userStateJson
mutableOrganizationsFlow.value = listOf(organizations)
mutablePolicyFlow.value = listOf(storedPolicyOne)
policyManager
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
.test {
assertEquals(listOf(expectedPolicyOne), awaitItem())
mutablePolicyFlow.value = listOf(storedPolicyOne, storedPolicyTwo)
assertEquals(listOf(expectedPolicyOne, expectedPolicyTwo), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `getActivePoliciesFlow when feature flag is false should emit empty list when organization status is below ACCEPTED`() =
runTest {
mutablePoliciesInAcceptedStateFlagFlow.value = false
val userStateJson = mockk<UserStateJson> {
every { activeUserId } returns USER_ID
}
val organizations = createMockOrganizationNetwork(
number = 1,
isEnabled = true,
shouldUsePolicies = true,
status = OrganizationStatusType.INVITED,
)
val storedPolicy = createMockPolicy(
isEnabled = true,
number = 1,
organizationId = organizations.id,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
)
mutableUserStateFlow.value = userStateJson
mutableOrganizationsFlow.value = listOf(organizations)
mutablePolicyFlow.value = listOf(storedPolicy)
policyManager
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
.test {
assertEquals(emptyList<PolicyView>(), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `getActivePoliciesFlow when feature flag is false should emit empty list when organization does not use policies`() =
runTest {
mutablePoliciesInAcceptedStateFlagFlow.value = false
val userStateJson = mockk<UserStateJson> {
every { activeUserId } returns USER_ID
}
val organizations = createMockOrganizationNetwork(
number = 1,
isEnabled = true,
shouldUsePolicies = false,
)
val storedPolicy = createMockPolicy(
isEnabled = true,
number = 1,
organizationId = organizations.id,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
)
mutableUserStateFlow.value = userStateJson
mutableOrganizationsFlow.value = listOf(organizations)
mutablePolicyFlow.value = listOf(storedPolicy)
policyManager
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
.test {
assertEquals(emptyList<PolicyView>(), awaitItem())
}
}
@Test
fun `getActivePolicies returns empty list if user id is null`() {
every {
authDiskSource.userState
} returns null
every { authDiskSource.userState } returns null
assertTrue(policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD).isEmpty())
}
@@ -127,6 +261,13 @@ class PolicyManagerTest {
isEnabled = true,
),
)
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = any(),
)
} returns emptyList<PolicyView>().asSuccess()
assertTrue(policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD).isEmpty())
}
@@ -153,6 +294,13 @@ class PolicyManagerTest {
isEnabled = true,
),
)
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = any(),
)
} returns emptyList<PolicyView>().asSuccess()
assertTrue(policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD).isEmpty())
}
@@ -176,6 +324,9 @@ class PolicyManagerTest {
),
)
every { authDiskSource.getPolicies(USER_ID) } returns listOf(storedPolicy)
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns listOf(expectedPolicy).asSuccess()
assertEquals(
listOf(expectedPolicy),
@@ -207,6 +358,16 @@ class PolicyManagerTest {
type = PolicyTypeJson.PASSWORD_GENERATOR,
),
)
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns listOf(
createMockPolicyView(
organizationId = "mockId-3",
enabled = true,
type = PolicyType.PASSWORD_GENERATOR,
),
)
.asSuccess()
assertTrue(policyManager.getActivePolicies(type = PolicyType.PASSWORD_GENERATOR).any())
}
@@ -235,6 +396,16 @@ class PolicyManagerTest {
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
),
)
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns listOf(
createMockPolicyView(
organizationId = "mockId-3",
enabled = true,
type = PolicyType.RESTRICTED_ITEM_TYPES,
),
)
.asSuccess()
assertTrue(
policyManager.getActivePolicies(type = PolicyType.RESTRICTED_ITEM_TYPES).any(),
@@ -243,13 +414,9 @@ class PolicyManagerTest {
@Test
fun `getUserPolicies returns empty list if policies is null`() {
every {
authDiskSource.userState
} returns null
every {
authDiskSource.getPolicies(USER_ID)
} returns null
every { authDiskSource.userState } returns null
every { authDiskSource.getPolicies(USER_ID) } returns null
every { authDiskSource.getOrganizations(USER_ID) } returns null
assertEquals(
emptyList<SyncResponseJson.Policy>(),
@@ -294,6 +461,9 @@ class PolicyManagerTest {
every {
authDiskSource.getPolicies(USER_ID)
} returns storedListOfPolicies
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns expectedListOfPolicies.asSuccess()
assertEquals(
expectedListOfPolicies,
@@ -348,6 +518,13 @@ class PolicyManagerTest {
type = PolicyTypeJson.PERSONAL_OWNERSHIP,
),
)
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = any(),
)
} returns emptyList<PolicyView>().asSuccess()
assertNull(policyManager.getPersonalOwnershipPolicyOrganizationId())
}
@@ -378,6 +555,15 @@ class PolicyManagerTest {
type = PolicyTypeJson.PERSONAL_OWNERSHIP,
),
)
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns listOf(
createMockPolicyView(
organizationId = expectedOrganizationId,
enabled = true,
),
)
.asSuccess()
assertEquals(
expectedOrganizationId,
@@ -445,6 +631,29 @@ class PolicyManagerTest {
revisionDate = middleRevisionDate,
),
)
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns listOf(
createMockPolicyView(
number = 3,
organizationId = "mockId-3",
enabled = true,
revisionDate = latestRevisionDate,
),
createMockPolicyView(
number = 1,
organizationId = expectedOrganizationId,
enabled = true,
revisionDate = earliestRevisionDate,
),
createMockPolicyView(
number = 2,
organizationId = "mockId-2",
enabled = true,
revisionDate = middleRevisionDate,
),
)
.asSuccess()
assertEquals(
expectedOrganizationId,
@@ -497,6 +706,17 @@ class PolicyManagerTest {
revisionDate = laterRevisionDate,
),
)
every {
authSdkSource.filterPolicies(any(), any(), any())
} returns listOf(
createMockPolicyView(
number = 2,
organizationId = expectedOrganizationId,
enabled = true,
revisionDate = laterRevisionDate,
),
)
.asSuccess()
// Should return mockId-2 because mockId-1's organization doesn't enforce policies
assertEquals(

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
@@ -99,6 +100,7 @@ class SdkClientManagerTest {
}
private fun createSdkClientManager(): SdkClientManagerImpl = SdkClientManagerImpl(
dispatcherManager = FakeDispatcherManager(),
clientProvider = { _, _ -> mockk(relaxed = true) },
nativeLibraryManager = mockNativeLibraryManager,
featureFlagManager = mockk(),

View File

@@ -0,0 +1,27 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
/**
* Create a mock [OrganizationUserPolicyContext] with a given [number].
*/
@Suppress("LongParameterList")
fun createMockOrganizationUserPolicyContext(
number: Int = 1,
id: String = "mockId-$number",
status: OrganizationUserStatusType = OrganizationUserStatusType.ACCEPTED,
role: OrganizationUserType = OrganizationUserType.ADMIN,
enabled: Boolean = false,
usePolicies: Boolean = false,
isProviderUser: Boolean = false,
): OrganizationUserPolicyContext =
OrganizationUserPolicyContext(
id = id,
status = status,
role = role,
enabled = enabled,
usePolicies = usePolicies,
isProviderUser = isProviderUser,
)

View File

@@ -0,0 +1,75 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockOrganizationUserPolicyContext
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
class VaultSdkOrganizationExtensionsTest {
@Test
fun `toSdkOrganizationPolicyContexts should return empty list when given empty list`() {
assertEquals(
emptyList<OrganizationUserPolicyContext>(),
emptyList<SyncResponseJson.Profile.Organization>().toSdkOrganizationPolicyContexts(),
)
}
@Test
fun `toSdkOrganizationPolicyContexts should convert all organizations in a list`() {
assertEquals(
listOf(
createMockOrganizationUserPolicyContext(number = 1),
createMockOrganizationUserPolicyContext(number = 2),
),
listOf(
createMockOrganizationNetwork(number = 1),
createMockOrganizationNetwork(number = 2),
)
.toSdkOrganizationPolicyContexts(),
)
}
@Test
fun `toSdkOrganizationPolicyContexts should map all OrganizationStatusType values`() {
STATUS_TYPE_MAP.forEach { (inputStatus, expectedStatus) ->
assertEquals(
listOf(createMockOrganizationUserPolicyContext(status = expectedStatus)),
listOf(createMockOrganizationNetwork(number = 1, status = inputStatus))
.toSdkOrganizationPolicyContexts(),
)
}
}
@Test
fun `toSdkOrganizationPolicyContexts should map all OrganizationType values`() {
ORGANIZATION_TYPE_MAP.forEach { (inputType, expectedRole) ->
assertEquals(
listOf(createMockOrganizationUserPolicyContext(role = expectedRole)),
listOf(createMockOrganizationNetwork(number = 1, type = inputType))
.toSdkOrganizationPolicyContexts(),
)
}
}
}
private val STATUS_TYPE_MAP: Map<OrganizationStatusType, OrganizationUserStatusType> = mapOf(
OrganizationStatusType.REVOKED to OrganizationUserStatusType.REVOKED,
OrganizationStatusType.INVITED to OrganizationUserStatusType.INVITED,
OrganizationStatusType.ACCEPTED to OrganizationUserStatusType.ACCEPTED,
OrganizationStatusType.CONFIRMED to OrganizationUserStatusType.CONFIRMED,
)
private val ORGANIZATION_TYPE_MAP: Map<OrganizationType, OrganizationUserType> = mapOf(
OrganizationType.OWNER to OrganizationUserType.OWNER,
OrganizationType.ADMIN to OrganizationUserType.ADMIN,
OrganizationType.USER to OrganizationUserType.USER,
OrganizationType.MANAGER to OrganizationUserType.ADMIN,
OrganizationType.CUSTOM to OrganizationUserType.CUSTOM,
)

View File

@@ -44,6 +44,7 @@ sealed class FlagKey<out T : Any> {
NewItemTypes,
DebugDisableSelfHostPremiumCheck,
FillAssistTargetingRules,
PoliciesInAcceptedState,
)
}
}
@@ -161,6 +162,14 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key for the Policies In Accepted State feature.
*/
data object PoliciesInAcceptedState : FlagKey<Boolean>() {
override val keyName: String get() = "pm-34145-policies-in-accepted-state"
override val defaultValue: Boolean get() = false
}
/**
* Debug-only flag that, when enabled, makes self-hosted environments behave as cloud
* environments for premium-upgrade gating. Used by QA to test the premium upgrade flow

View File

@@ -56,6 +56,10 @@ class FlagKeyTest {
FlagKey.ManageDevices.keyName,
"pm-4516-devices-add-last-activity-date",
)
assertEquals(
FlagKey.PoliciesInAcceptedState.keyName,
"pm-34145-policies-in-accepted-state",
)
}
@Test
@@ -74,6 +78,7 @@ class FlagKeyTest {
FlagKey.NewItemTypes,
FlagKey.FillAssistTargetingRules,
FlagKey.ManageDevices,
FlagKey.PoliciesInAcceptedState,
).all {
!it.defaultValue
},

View File

@@ -10,6 +10,12 @@ import kotlinx.serialization.Serializable
*/
@Serializable(OrganizationStatusTypeSerializer::class)
enum class OrganizationStatusType {
/**
* The user has been revoked from the organization.
*/
@SerialName("-1")
REVOKED,
/**
* The user has been invited to the organization.
*/

View File

@@ -311,6 +311,9 @@ data class SyncResponseJson(
@SerialName("providerType")
val providerType: Int?,
@SerialName("isProviderUser")
val isProviderUser: Boolean = false,
@SerialName("maxCollections")
val maxCollections: Int?,

View File

@@ -38,6 +38,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.NewItemTypes,
FlagKey.FillAssistTargetingRules,
FlagKey.DebugDisableSelfHostPremiumCheck,
FlagKey.PoliciesInAcceptedState,
-> {
@Suppress("UNCHECKED_CAST")
BooleanFlagItem(
@@ -95,10 +96,8 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.V2EncryptionPassword -> stringResource(BitwardenString.v2_encryption_password)
FlagKey.V2EncryptionTde -> stringResource(BitwardenString.v2_encryption_tde)
FlagKey.NewItemTypes -> stringResource(BitwardenString.new_item_types)
FlagKey.FillAssistTargetingRules -> {
stringResource(BitwardenString.fill_assist_targeting_rules)
}
FlagKey.FillAssistTargetingRules -> stringResource(BitwardenString.fill_assist_targeting_rules)
FlagKey.PoliciesInAcceptedState -> stringResource(BitwardenString.policies_in_accepted_state)
FlagKey.DebugDisableSelfHostPremiumCheck -> {
stringResource(BitwardenString.debug_disable_self_host_premium_check)
}

View File

@@ -54,6 +54,7 @@
<string name="new_item_types">New Item Types</string>
<string name="fill_assist_targeting_rules">Fill Assist Targeting Rules</string>
<string name="debug_disable_self_host_premium_check">Debug: Disable self-host premium check</string>
<string name="policies_in_accepted_state">Policies in accepted state</string>
<!-- endregion Debug Menu -->
</resources>