Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ user.properties
/app/src/standardRelease/google-services.json
/authenticator/src/google-services.json

# Claude Code outputs
.claude/outputs/

# Python
.python-version
__pycache__/
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ configurations.all {
resolutionStrategy.dependencySubstitution {
if ((userProperties["localSdk"] as String?).toBoolean()) {
substitute(module("com.bitwarden:sdk-android"))
.using(module("com.bitwarden:sdk-android:LOCAL"))
.using(module("com.bitwarden:sdk-android.dev:LOCAL"))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import com.bitwarden.network.model.OrganizationType
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
* @property limitItemDeletion Indicates that the organization limits item deletion.
* @property shouldUseEvents Indicates if the organization uses tracking events.
* @property maxCollections The maximum number of collections allowed (nullable).
* @property canCreateNewCollections Indicates if the user can create new collections.
* @property canEditAnyCollection Indicates if the user can edit any collection.
* @property canDeleteAnyCollection Indicates if the user can delete any collection.
*/
data class Organization(
val id: String,
Expand All @@ -26,4 +30,22 @@ data class Organization(
val userIsClaimedByOrganization: Boolean,
val limitItemDeletion: Boolean,
val shouldUseEvents: Boolean,
)
val maxCollections: Int?,
val limitCollectionCreation: Boolean,
val limitCollectionDeletion: Boolean,
val organizationUserId: String?,
val canCreateNewCollections: Boolean,
val canEditAnyCollection: Boolean,
val canDeleteAnyCollection: Boolean,
) {
/**
* Whether the user can create new collections in this organization, accounting for
* the organization's role and limitCollectionCreation setting.
* Matches web client logic: `!limitCollectionCreation || isAdmin || permissions.createNewCollections`
*/
val canManageCollections: Boolean
get() = !limitCollectionCreation ||
role == OrganizationType.ADMIN ||
role == OrganizationType.OWNER ||
canCreateNewCollections
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
shouldUseEvents = this.shouldUseEvents,
maxCollections = this.maxCollections,
organizationUserId = this.organizationUserId,
limitCollectionCreation = this.limitCollectionCreation,
limitCollectionDeletion = this.limitCollectionDeletion,
canCreateNewCollections = this.permissions.canCreateNewCollections,
canEditAnyCollection = this.permissions.canEditAnyCollection,
canDeleteAnyCollection = this.permissions.canDeleteAnyCollection,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class ServerCommunicationConfigRepositoryImpl(
idpLoginUrl = serverCommunicationConfig.bootstrap.idpLoginUrl,
cookieName = serverCommunicationConfig.bootstrap.cookieName,
cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain,
vaultUrl = null,
cookieValue = acquiredCookies,
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ interface VaultDiskSource {
*/
fun getCollectionsFlow(userId: String): Flow<List<SyncResponseJson.Collection>>

/**
* Deletes a collection from the data source for the given [userId] and [collectionId].
*/
suspend fun deleteCollection(userId: String, collectionId: String)

/**
* Retrieves all domains from the data source for a given [userId].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ class VaultDiskSourceImpl(
)
}

override suspend fun deleteCollection(userId: String, collectionId: String) {
collectionsDao.deleteCollection(userId = userId, collectionId = collectionId)
}

override fun getCollectionsFlow(
userId: String,
): Flow<List<SyncResponseJson.Collection>> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
Expand All @@ -25,6 +26,12 @@ object VaultNetworkModule {
bitwardenServiceClient: BitwardenServiceClient,
): CiphersService = bitwardenServiceClient.ciphersService

@Provides
@Singleton
fun providesCollectionService(
bitwardenServiceClient: BitwardenServiceClient,
): CollectionService = bitwardenServiceClient.collectionService

@Provides
@Singleton
fun providesFolderService(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ interface VaultSdkSource {
collectionList: List<Collection>,
): Result<List<CollectionView>>

/**
* Encrypts a [CollectionView] for the user with the given [userId], returning a [Collection]
* wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun encryptCollection(
userId: String,
collectionView: CollectionView,
): Result<Collection>

/**
* Encrypts a [SendView] for the user with the given [userId], returning a [Send] wrapped
* in a [Result].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,17 @@ class VaultSdkSourceImpl(
.decryptList(collections = collectionList)
}

override suspend fun encryptCollection(
userId: String,
collectionView: CollectionView,
): Result<Collection> =
runCatchingWithLogs {
getClient(userId = userId)
.vault()
.collections()
.encrypt(collectionView = collectionView)
}

override suspend fun decryptSend(
userId: String,
send: Send,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.vault.manager

import com.bitwarden.collections.CollectionView
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult

/**
* Manages the creating, updating, and deleting collections.
*/
interface CollectionManager {
/**
* Attempt to create a collection in the given [organizationId].
* The [organizationUserId] is used to grant the creating user manage access.
*/
suspend fun createCollection(
organizationId: String,
organizationUserId: String?,
collectionView: CollectionView,
): CreateCollectionResult

/**
* Attempt to delete a collection.
*/
suspend fun deleteCollection(
organizationId: String,
collectionId: String,
): DeleteCollectionResult

/**
* Attempt to update a collection.
*/
suspend fun updateCollection(
organizationId: String,
collectionId: String,
collectionView: CollectionView,
): UpdateCollectionResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.x8bit.bitwarden.data.vault.manager

import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.CollectionAccessSelectionJson
import com.bitwarden.network.model.CollectionJsonRequest
import com.bitwarden.network.model.UpdateCollectionResponseJson
import com.bitwarden.network.service.CollectionService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollection

/**
* The default implementation of the [CollectionManager].
*/
class CollectionManagerImpl(
private val authDiskSource: AuthDiskSource,
private val collectionService: CollectionService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
) : CollectionManager {
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId

override suspend fun createCollection(
organizationId: String,
organizationUserId: String?,
collectionView: CollectionView,
): CreateCollectionResult {
val userId = activeUserId
?: return CreateCollectionResult.Error(error = NoActiveUserException())
// Grant the creating user manage access, matching web client behavior.
val users = organizationUserId?.let {
listOf(
CollectionAccessSelectionJson(
id = it,
readOnly = false,
hidePasswords = false,
manage = true,
),
)
}
return vaultSdkSource
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thought: Encrypt-only in SDK was really only meant as a transition phase while API, state were not mature in the SDK, and the expectation is to move state and API to the SDK too. In this case, moving at least API one layer down into the SDK would already reduce this PR by a large margin, and remove redundant work from the corresponding iOS PR and would allow dropping TS clients code.

Given that this is a net new feature, can we consider just moving this functionality into the SDK in the first place instead of duplicating logic? Moving this down later will cause more effort overall compared to doing it here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

While that is all true, I think introducing SDK managed state and network calls are out of scope for this PR. The Mobile apps have not migrated to using the SDK for state or network calls anywhere, so this would require a non-trivial change in architecture and introduces a second pattern that we have to actively maintain.

.encryptCollection(userId = userId, collectionView = collectionView)
.flatMap {
collectionService.createCollection(
organizationId = organizationId,
body = CollectionJsonRequest(
name = it.name,
users = users,
),
)
}
.onSuccess {
vaultDiskSource.saveCollection(userId = userId, collection = it)
}
.flatMap {
vaultSdkSource.decryptCollection(
userId = userId,
collection = it.toEncryptedSdkCollection(),
)
}
.fold(
onSuccess = { CreateCollectionResult.Success(collectionView = it) },
onFailure = { CreateCollectionResult.Error(error = it) },
)
}

override suspend fun deleteCollection(
organizationId: String,
collectionId: String,
): DeleteCollectionResult {
val userId = activeUserId
?: return DeleteCollectionResult.Error(error = NoActiveUserException())
return collectionService
.deleteCollection(
organizationId = organizationId,
collectionId = collectionId,
)
.onSuccess {
vaultDiskSource.deleteCollection(
userId = userId,
collectionId = collectionId,
)
}
.fold(
onSuccess = { DeleteCollectionResult.Success },
onFailure = { DeleteCollectionResult.Error(error = it) },
)
}

@Suppress("LongMethod")
override suspend fun updateCollection(
organizationId: String,
collectionId: String,
collectionView: CollectionView,
): UpdateCollectionResult {
val userId = activeUserId
?: return UpdateCollectionResult.Error(error = NoActiveUserException())
return collectionService
.getCollectionDetails(
organizationId = organizationId,
collectionId = collectionId,
)
.flatMap { details ->
vaultSdkSource
.encryptCollection(
userId = userId,
collectionView = collectionView,
)
.flatMap { collection ->
collectionService.updateCollection(
organizationId = organizationId,
collectionId = collectionId,
body = CollectionJsonRequest(
name = collection.name,
externalId = details.externalId,
groups = details.groups,
users = details.users,
),
)
}
}
.fold(
onSuccess = { response ->
when (response) {
is UpdateCollectionResponseJson.Success -> {
vaultDiskSource.saveCollection(
userId = userId,
collection = response.collection,
)
vaultSdkSource
.decryptCollection(
userId = userId,
collection = response.collection
.toEncryptedSdkCollection(),
)
.fold(
onSuccess = {
UpdateCollectionResult.Success(it)
},
onFailure = {
UpdateCollectionResult.Error(error = it)
},
)
}

is UpdateCollectionResponseJson.Invalid -> {
UpdateCollectionResult.Error(
errorMessage = response.message,
error = null,
)
}
}
},
onFailure = { UpdateCollectionResult.Error(error = it) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
Expand All @@ -28,6 +29,8 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CipherManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
import com.x8bit.bitwarden.data.vault.manager.FolderManager
Expand Down Expand Up @@ -114,6 +117,20 @@ object VaultManagerModule {
pushManager = pushManager,
)

@Provides
@Singleton
fun provideCollectionManager(
collectionService: CollectionService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
): CollectionManager = CollectionManagerImpl(
authDiskSource = authDiskSource,
collectionService = collectionService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
)

@Provides
@Singleton
fun provideFolderManager(
Expand Down
Loading
Loading