[PM-18092] Update cipher delete restore permissions (#5075)

Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
This commit is contained in:
André Bispo
2025-05-05 13:56:58 +00:00
committed by GitHub
co-authored by Patrick Honkonen
parent 639ca02739
commit 1a2beea770
30 changed files with 655 additions and 14 deletions
@@ -41,6 +41,7 @@ sealed class FlagKey<out T : Any> {
ChromeAutofill,
MobileErrorReporting,
FlightRecorder,
RestrictCipherItemDeletion,
PreAuthSettings,
)
}
@@ -194,6 +195,15 @@ sealed class FlagKey<out T : Any> {
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the feature flag key to enable the restriction of cipher item deletion
*/
data object RestrictCipherItemDeletion : FlagKey<Boolean>() {
override val keyName: String = "pm-15493-restrict-item-deletion-to-can-manage-permission"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
/**
* Data object holding the feature flag key to enable the settings menu before login.
*/
@@ -4,6 +4,7 @@ import com.bitwarden.core.annotation.OmitFromCoverage
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
@@ -24,15 +25,23 @@ class Fido2CredentialStoreImpl(
/**
* Return all active ciphers that contain FIDO 2 credentials.
*/
override suspend fun allCredentials(): List<CipherView> {
override suspend fun allCredentials(): List<CipherListView> {
val syncResult = vaultRepository.syncForResult()
if (syncResult is SyncVaultDataResult.Error) {
syncResult.throwable
?.let { throw it }
?: throw IllegalStateException("Sync failed.")
}
return vaultRepository.ciphersStateFlow.value.data
val activeCipherIds = vaultRepository.ciphersStateFlow.value.data
?.filter { it.isActiveWithFido2Credentials }
?.map { it.id }
?: emptyList()
return vaultRepository.ciphersListViewStateFlow.value.data
?.filter { clv ->
activeCipherIds
.contains(clv.id)
}
?: emptyList()
}
@@ -8,6 +8,7 @@ import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.FolderView
@@ -65,6 +66,14 @@ interface VaultRepository : CipherManager, VaultLockManager {
*/
val ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
/**
* Flow that represents all ciphers for the active user.
*
* Note that the [StateFlow.value] will return the last known value but the [StateFlow] itself
* must be collected in order to trigger state changes.
*/
val ciphersListViewStateFlow: StateFlow<DataState<List<CipherListView>>>
/**
* Flow that represents all collections for the active user.
*
@@ -30,6 +30,7 @@ import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CollectionView
@@ -169,6 +170,9 @@ class VaultRepositoryImpl(
private val mutableCiphersStateFlow =
MutableStateFlow<DataState<List<CipherView>>>(DataState.Loading)
private val mutableCiphersListViewStateFlow =
MutableStateFlow<DataState<List<CipherListView>>>(DataState.Loading)
private val mutableFoldersStateFlow =
MutableStateFlow<DataState<List<FolderView>>>(DataState.Loading)
@@ -214,6 +218,9 @@ class VaultRepositoryImpl(
override val ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
get() = mutableCiphersStateFlow.asStateFlow()
override val ciphersListViewStateFlow: StateFlow<DataState<List<CipherListView>>>
get() = mutableCiphersListViewStateFlow.asStateFlow()
override val domainsStateFlow: StateFlow<DataState<DomainsData>>
get() = mutableDomainsStateFlow.asStateFlow()
@@ -260,6 +267,15 @@ class VaultRepositoryImpl(
}
.launchIn(unconfinedScope)
mutableCiphersListViewStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultUnlockDataStateFlow,
) { activeUserId ->
observeVaultDiskCiphersToCipherListView(activeUserId)
}
.launchIn(unconfinedScope)
// Setup domains MutableStateFlow
mutableDomainsStateFlow
.observeWhenSubscribedAndLoggedIn(
@@ -1071,6 +1087,27 @@ class VaultRepositoryImpl(
.map { it.orLoadingIfNotSynced(userId = userId) }
.onEach { mutableCiphersStateFlow.value = it }
private fun observeVaultDiskCiphersToCipherListView(
userId: String,
): Flow<DataState<List<CipherListView>>> =
vaultDiskSource
.getCiphers(userId = userId)
.onStart { mutableCiphersListViewStateFlow.updateToPendingOrLoading() }
.map {
waitUntilUnlocked(userId = userId)
vaultSdkSource
.decryptCipherListCollection(
userId = userId,
cipherList = it.toEncryptedSdkCipherList(),
)
.fold(
onSuccess = { ciphers -> DataState.Loaded(ciphers.sortAlphabetically()) },
onFailure = { throwable -> DataState.Error(throwable) },
)
}
.map { it.orLoadingIfNotSynced(userId = userId) }
.onEach { mutableCiphersListViewStateFlow.value = it }
private fun observeVaultDiskDomains(
userId: String,
): Flow<DataState<DomainsData>> =
@@ -15,6 +15,8 @@ import com.bitwarden.network.model.UriMatchTypeJson
import com.bitwarden.vault.Attachment
import com.bitwarden.vault.Card
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherPermissions
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
@@ -68,6 +70,7 @@ fun Cipher.toEncryptedNetworkCipherResponse(): SyncResponseJson.Cipher =
notes = notes,
reprompt = reprompt.toNetworkRepromptType(),
passwordHistory = passwordHistory?.toEncryptedNetworkPasswordHistoryList(),
permissions = permissions?.toEncryptedNetworkCipherPermissions(),
type = type.toNetworkCipherType(),
login = login?.toEncryptedNetworkLogin(),
secureNote = secureNote?.toEncryptedNetworkSecureNote(),
@@ -299,6 +302,17 @@ private fun PasswordHistory.toEncryptedNetworkPasswordHistory(): SyncResponseJso
lastUsedDate = ZonedDateTime.ofInstant(lastUsedDate, ZoneOffset.UTC),
)
/**
* Converts a Bitwarden SDK [CipherPermissions] object to a corresponding
* [SyncResponseJson.Cipher.CipherPermissions] object.
*/
@Suppress("MaxLineLength")
private fun CipherPermissions.toEncryptedNetworkCipherPermissions(): SyncResponseJson.Cipher.CipherPermissions =
SyncResponseJson.Cipher.CipherPermissions(
delete = delete,
restore = restore,
)
/**
* Converts a Bitwarden SDK [CipherRepromptType] object to a corresponding
* [CipherRepromptTypeJson] object.
@@ -357,6 +371,7 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher =
attachments = attachments?.toSdkAttachmentList(),
fields = fields?.toSdkFieldList(),
passwordHistory = passwordHistory?.toSdkPasswordHistoryList(),
permissions = permissions?.toSdkPermissions(),
creationDate = creationDate.toInstant(),
deletedDate = deletedDate?.toInstant(),
revisionDate = revisionDate.toInstant(),
@@ -531,6 +546,16 @@ fun SyncResponseJson.Cipher.PasswordHistory.toSdkPasswordHistory(): PasswordHist
lastUsedDate = lastUsedDate.toInstant(),
)
/**
* Transforms a [SyncResponseJson.Cipher.CipherPermissions] into
* a corresponding Bitwarden SDK [CipherPermissions].
*/
fun SyncResponseJson.Cipher.CipherPermissions.toSdkPermissions(): CipherPermissions =
CipherPermissions(
delete = delete,
restore = restore,
)
/**
* Transforms a [CipherTypeJson] to the corresponding Bitwarden SDK [CipherType].
*/
@@ -588,3 +613,16 @@ fun List<CipherView>.sortAlphabetically(): List<CipherView> {
},
)
}
/**
* Sorts the data in alphabetical order by name. Using lexicographical sorting but giving
* precedence to special characters over letters and digits.
*/
@JvmName("toAlphabeticallySortedCipherListView")
fun List<CipherListView>.sortAlphabetically(): List<CipherListView> {
return this.sortedWith(
comparator = { cipher1, cipher2 ->
SpecialCharWithPrecedenceComparator.compare(cipher1.name, cipher2.name)
},
)
}
@@ -41,6 +41,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.ChromeAutofill,
FlagKey.MobileErrorReporting,
FlagKey.FlightRecorder,
FlagKey.RestrictCipherItemDeletion,
FlagKey.PreAuthSettings,
-> {
@Suppress("UNCHECKED_CAST")
@@ -102,5 +103,6 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.ChromeAutofill -> stringResource(R.string.enable_chrome_autofill)
FlagKey.MobileErrorReporting -> stringResource(R.string.enable_error_reporting_dialog)
FlagKey.FlightRecorder -> stringResource(R.string.enable_flight_recorder)
FlagKey.RestrictCipherItemDeletion -> stringResource(R.string.restrict_item_deletion)
FlagKey.PreAuthSettings -> stringResource(R.string.enable_pre_auth_settings)
}
@@ -26,12 +26,14 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResu
import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement
import com.x8bit.bitwarden.data.autofill.fido2.util.getCreatePasskeyCredentialRequestOrNull
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull
@@ -119,6 +121,7 @@ class VaultAddEditViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
private val networkConnectionManager: NetworkConnectionManager,
private val firstTimeActionManager: FirstTimeActionManager,
private val featureFlagManager: FeatureFlagManager,
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
@@ -1719,6 +1722,10 @@ class VaultAddEditViewModel @Inject constructor(
vaultData: VaultData?,
userData: UserState?,
): VaultAddEditState {
val restrictCipherItemDeletionEnabled = featureFlagManager
.getFeatureFlag(
FlagKey.RestrictCipherItemDeletion,
)
val internalVaultData = vaultData
?: VaultData(
cipherViewList = emptyList(),
@@ -1737,10 +1744,15 @@ class VaultAddEditViewModel @Inject constructor(
currentAccount = userData?.activeAccount,
vaultAddEditType = vaultAddEditType,
) { currentAccount, cipherView ->
val canDelete = internalVaultData
.collectionViewList
.hasDeletePermissionInAtLeastOneCollection(cipherView?.collectionIds)
val canDelete = if (restrictCipherItemDeletionEnabled &&
cipherView?.permissions?.delete != null
) {
cipherView.permissions?.delete == true
} else {
internalVaultData
.collectionViewList
.hasDeletePermissionInAtLeastOneCollection(cipherView?.collectionIds)
}
val canAssignToCollections = internalVaultData
.collectionViewList
@@ -2241,16 +2253,21 @@ data class VaultAddEditState(
* This is only present when editing a pre-existing cipher.
* @property name Represents the name for the item type. This is an abstract property
* that must be overridden to save the item.
* @property isUnlockWithPasswordEnabled Indicates whether the user is allowed to
* unlock with a password.
* @property masterPasswordReprompt Indicates if a master password reprompt is required.
* @property favorite Indicates whether this item is marked as a favorite.
* @property customFieldData Additional custom fields associated with the item.
* @property notes Any additional notes or comments associated with the item.
* @property selectedCollectionId The ID of the collection that this item belongs to.
* @property selectedFolderId The ID of the folder that this item belongs to.
* @property availableFolders The list of folders that this item could be added too.
* @property selectedOwnerId The ID of the owner associated with the item.
* @property availableOwners A list of available owners.
* @property hasOrganizations Indicates if the user is part of any organizations.
* @property canDelete Indicates whether the current user can delete the item.
* @property canAssignToCollections Indicates whether the current user can assign the
* item to a collection.
*/
@Parcelize
data class Common(
@@ -166,7 +166,7 @@ fun VaultItemScreen(
{ viewModel.trySendAction(VaultItemAction.Common.CloseClick) }
},
actions = {
if (state.isCipherDeleted && state.canDelete) {
if (state.canRestore) {
BitwardenTextButton(
label = stringResource(id = R.string.restore),
onClick = remember(viewModel) {
@@ -17,8 +17,10 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -70,6 +72,7 @@ class VaultItemViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val featureFlagManager: FeatureFlagManager,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -105,6 +108,10 @@ class VaultItemViewModel @Inject constructor(
vaultRepository.collectionsStateFlow,
vaultRepository.foldersStateFlow,
) { cipherViewState, userState, authCodeState, collectionsState, folderState ->
val restrictCipherItemDeletionEnabled = featureFlagManager
.getFeatureFlag(
FlagKey.RestrictCipherItemDeletion,
)
val totpCodeData = authCodeState.data?.let {
TotpCodeItemData(
periodSeconds = it.periodSeconds,
@@ -126,10 +133,24 @@ class VaultItemViewModel @Inject constructor(
}
.mapNullable {
val cipherView = cipherViewState.data
val canDelete = collectionsState.data
.hasDeletePermissionInAtLeastOneCollection(
val canDelete = if (restrictCipherItemDeletionEnabled &&
cipherView?.permissions?.delete != null
) {
cipherView.permissions?.delete == true
} else {
collectionsState.data.hasDeletePermissionInAtLeastOneCollection(
collectionIds = cipherView?.collectionIds,
)
}
val canRestore = if (restrictCipherItemDeletionEnabled &&
cipherView?.permissions?.restore != null
) {
cipherView.permissions?.restore == true &&
cipherView.deletedDate != null
} else {
canDelete && cipherView?.deletedDate != null
}
val canAssignToCollections = collectionsState.data
.canAssignToCollections(cipherView?.collectionIds)
@@ -167,6 +188,7 @@ class VaultItemViewModel @Inject constructor(
cipher = cipherView,
totpCodeItemData = totpCodeData,
canDelete = canDelete,
canRestore = canRestore,
canAssociateToCollections = canAssignToCollections,
canEdit = canEdit,
relatedLocations = relatedLocations,
@@ -1200,6 +1222,7 @@ class VaultItemViewModel @Inject constructor(
hasMasterPassword = account.hasMasterPassword,
totpCodeItemData = this.data?.totpCodeItemData,
canDelete = this.data?.canDelete == true,
canRestore = this.data?.canRestore == true,
canAssignToCollections = this.data?.canAssociateToCollections == true,
canEdit = this.data?.canEdit == true,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
@@ -1496,6 +1519,14 @@ data class VaultItemState(
?.common
?.canDelete == true
/**
* Whether or not the cipher can be deleted.
*/
val canRestore: Boolean
get() = viewState.asContentOrNull()
?.common
?.canRestore == true
val canAssignToCollections: Boolean
get() = viewState.asContentOrNull()
?.common
@@ -1555,6 +1586,7 @@ data class VaultItemState(
* @property currentCipher The cipher that is currently being viewed (nullable).
* @property attachments A list of attachments associated with the cipher.
* @property canDelete Indicates if the cipher can be deleted.
* @property canRestore Indicates if the cipher can be restored.
* @property canAssignToCollections Indicates if the cipher can be assigned to
* collections.
* @property favorite Indicates that the cipher is favorite.
@@ -1572,6 +1604,7 @@ data class VaultItemState(
val currentCipher: CipherView? = null,
val attachments: List<AttachmentItem>?,
val canDelete: Boolean,
val canRestore: Boolean,
val canAssignToCollections: Boolean,
val canEdit: Boolean,
val favorite: Boolean,
@@ -17,6 +17,7 @@ data class VaultItemStateData(
val cipher: CipherView?,
val totpCodeItemData: TotpCodeItemData?,
val canDelete: Boolean,
val canRestore: Boolean,
val canAssociateToCollections: Boolean,
val canEdit: Boolean,
val relatedLocations: ImmutableList<VaultItemLocation>,
@@ -45,6 +45,7 @@ fun CipherView.toViewState(
totpCodeItemData: TotpCodeItemData?,
clock: Clock = Clock.systemDefaultZone(),
canDelete: Boolean,
canRestore: Boolean,
canAssignToCollections: Boolean,
canEdit: Boolean,
baseIconUrl: String,
@@ -98,6 +99,7 @@ fun CipherView.toViewState(
}
.orEmpty(),
canDelete = canDelete,
canRestore = canRestore,
canAssignToCollections = canAssignToCollections,
canEdit = canEdit,
favorite = this.favorite,
@@ -40,6 +40,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView =
attachments = common.originalCipher?.attachments,
organizationUseTotp = common.originalCipher?.organizationUseTotp ?: false,
passwordHistory = toPasswordHistory(),
permissions = common.originalCipher?.permissions,
creationDate = common.originalCipher?.creationDate ?: Instant.now(),
deletedDate = common.originalCipher?.deletedDate,
revisionDate = common.originalCipher?.revisionDate ?: Instant.now(),
@@ -26,6 +26,7 @@
<string name="enable_chrome_autofill">Enable chrome autofill</string>
<string name="enable_error_reporting_dialog">Enable error reporting dialog</string>
<string name="enable_flight_recorder">Enable flight recorder</string>
<string name="restrict_item_deletion">Restrict item deletion</string>
<string name="enable_pre_auth_settings">Enabled pre-auth settings</string>
<!-- /Debug Menu -->
</resources>