Skip to content

[PM-21577] Handle organization limitItemDeletion from sync response. #5244

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 22, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import com.bitwarden.network.model.OrganizationType
* @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property role The user's role in the organization.
* @property keyConnectorUrl The key connector domain (if applicable).
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
Copy link
Collaborator

Choose a reason for hiding this comment

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

๐Ÿ‘

* @property limitItemDeletion Indicates that the organization limits item deletion.
*/
data class Organization(
val id: String,
Expand All @@ -21,4 +23,5 @@ data class Organization(
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,
val limitItemDeletion: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1754,9 +1754,21 @@ class VaultAddEditViewModel @Inject constructor(
) {
cipherView.permissions?.delete == true
} else {
val needsManagePermission = cipherView
?.organizationId
?.let { orgId ->
currentAccount
.organizations
.firstOrNull { it.id == orgId }
?.limitItemDeletion
}

internalVaultData
.collectionViewList
.hasDeletePermissionInAtLeastOneCollection(cipherView?.collectionIds)
.hasDeletePermissionInAtLeastOneCollection(
collectionIds = cipherView?.collectionIds,
needsManagePermission = needsManagePermission == true,
)
}

val canAssignToCollections = internalVaultData
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,18 @@ class VaultItemViewModel @Inject constructor(
) {
cipherView.permissions?.delete == true
} else {
val needsManagePermission = cipherView
?.organizationId
?.let { orgId ->
userState
?.activeAccount
?.organizations
?.firstOrNull { it.id == orgId }
?.limitItemDeletion
}
collectionsState.data.hasDeletePermissionInAtLeastOneCollection(
collectionIds = cipherView?.collectionIds,
needsManagePermission = needsManagePermission == true,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,32 @@ fun String.toCollectionDisplayName(list: List<CollectionView>): String {
/**
* Checks if the user has delete permission in at least one collection.
*
* Deletion is allowed when the item is in any collection that the user has "manage" permission for.
* Deletion is allowed when the item is in any collection that the user has "manage" permission for
* if [needsManagePermission] is true. Otherwise, deletion is allowed when the item is in any with
* manage or edit permission.
*/
fun List<CollectionView>?.hasDeletePermissionInAtLeastOneCollection(
collectionIds: List<String>?,
needsManagePermission: Boolean = false,
): Boolean {
if (this.isNullOrEmpty() || collectionIds.isNullOrEmpty()) return true
return this
.any { collectionView ->
collectionIds
.contains(collectionView.id)
.let { isInCollection -> isInCollection && collectionView.manage }
.let { isInCollection ->
if (!isInCollection) {
return false
}

if (needsManagePermission) {
return collectionView.manage
}

return collectionView.manage ||
collectionView.permission == CollectionPermission.EDIT ||
collectionView.permission == CollectionPermission.EDIT_EXCEPT_PASSWORD
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4752,6 +4752,7 @@ class AuthRepositoryTest {
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns null
every { userIsClaimedByOrganization } returns false
every { limitItemDeletion } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
Expand Down Expand Up @@ -4782,6 +4783,7 @@ class AuthRepositoryTest {
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
every { limitItemDeletion } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
Expand Down Expand Up @@ -4820,6 +4822,7 @@ class AuthRepositoryTest {
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
every { limitItemDeletion } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
Expand Down Expand Up @@ -4861,6 +4864,7 @@ class AuthRepositoryTest {
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
every { limitItemDeletion } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
Expand Down Expand Up @@ -4901,6 +4905,7 @@ class AuthRepositoryTest {
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
every { limitItemDeletion } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = createCredentialRequest,
)
)
val fido2ContentState = createCredentialRequest.toDefaultAddTypeContent(
attestationOptions = createMockPasskeyAttestationOptions(number = 1),
isIndividualVaultDisabled = false,
Expand Down Expand Up @@ -1467,8 +1467,28 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {

@Suppress("MaxLineLength")
@Test
fun `in edit mode, canDelete should be false when cipher is in a collection the user cannot manage`() =
fun `in edit mode, canDelete should be false when cipher is in a collection the user cannot manage and org has limitItemDeletion`() =
runTest {
val userState = createUserState().copy(
accounts = listOf(
createUserState().accounts.first().copy(
organizations = listOf(
Organization(
id = "mockOrganizationId-1",
name = "Mock Organization Name 1",
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
limitItemDeletion = true,
),
),
),
),
)
mutableUserStateFlow.value = userState

val cipherView = createMockCipherView(1)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val stateWithName = createVaultAddItemState(
Expand Down Expand Up @@ -2115,7 +2135,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = mockFido2CredentialRequest,
)
)

setupFido2CreateRequest()

Expand Down Expand Up @@ -2174,7 +2194,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = mockFidoRequest,
)
)
setupFido2CreateRequest(
mockCallingAppInfo = mockCallingAppInfo,
mockCreatePublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest,
Expand Down Expand Up @@ -2254,7 +2274,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = mockFidoRequest,
)
)

setupFido2CreateRequest()

Expand Down Expand Up @@ -4501,7 +4521,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = mockRequest,
)
)
setupFido2CreateRequest()
every { authRepository.activeUserId } returns "activeUserId"
coEvery {
Expand Down Expand Up @@ -4529,7 +4549,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = mockRequest,
)
)
every { authRepository.activeUserId } returns "activeUserId"
coEvery {
bitwardenCredentialManager.registerFido2Credential(
Expand Down Expand Up @@ -4577,7 +4597,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = mockRequest,
)
)
setupFido2CreateRequest()
every { authRepository.activeUserId } returns "activeUserId"
coEvery {
Expand Down Expand Up @@ -4780,6 +4800,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
limitItemDeletion = false,
),
),
isBiometricsEnabled = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,56 @@ class CollectionViewExtensionsTest {

@Suppress("MaxLineLength")
@Test
fun `hasDeletePermissionInAtLeastOneCollection should return false if the user does not have manage permission in at least one collection`() {
fun `hasDeletePermissionInAtLeastOneCollection should return false if the user does not have manage permission in at least one collection and org has limitItemDeletion active`() {
val collectionList: List<CollectionView> = listOf(
createEditCollectionView(number = 1),
createEditExceptPasswordsCollectionView(number = 2),
createViewCollectionView(number = 3),
createViewExceptPasswordsCollectionView(number = 4),
)
val collectionIds = collectionList.mapNotNull { it.id }
assertFalse(collectionList.hasDeletePermissionInAtLeastOneCollection(collectionIds))
assertFalse(
collectionList.hasDeletePermissionInAtLeastOneCollection(
collectionIds = collectionIds,
needsManagePermission = true,
),
)
}

@Suppress("MaxLineLength")
@Test
fun `hasDeletePermissionInAtLeastOneCollection should return true if the user does not have manage permission in at least one collection and org has limitItemDeletion off and has edit permissions`() {
val collectionList: List<CollectionView> = listOf(
createEditCollectionView(number = 1),
createEditExceptPasswordsCollectionView(number = 2),
createViewCollectionView(number = 3),
createViewExceptPasswordsCollectionView(number = 4),
)
val collectionIds = collectionList.mapNotNull { it.id }
assertTrue(
collectionList.hasDeletePermissionInAtLeastOneCollection(
collectionIds = collectionIds,
needsManagePermission = false,
),
)
}

@Suppress("MaxLineLength")
@Test
fun `hasDeletePermissionInAtLeastOneCollection should return false if ids don't match any collection`() {
val collectionList: List<CollectionView> = listOf(
createEditCollectionView(number = 1),
createEditExceptPasswordsCollectionView(number = 2),
createViewCollectionView(number = 3),
createViewExceptPasswordsCollectionView(number = 4),
)
val collectionIds = listOf("notInCollectionId")
assertFalse(
collectionList.hasDeletePermissionInAtLeastOneCollection(
collectionIds = collectionIds,
needsManagePermission = false,
),
)
}

@Suppress("MaxLineLength")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ data class SyncResponseJson(
* @property familySponsorshipValidUntil The family sponsorship valid until
* of the organization (nullable).
* @property status The status of the organization.
* @property limitItemDeletion If the organization limits item deletion.
*/
@Serializable
data class Organization(
Expand Down Expand Up @@ -347,6 +348,9 @@ data class SyncResponseJson(

@SerialName("userIsClaimedByOrganization")
val userIsClaimedByOrganization: Boolean = false,

@SerialName("limitItemDeletion")
val limitItemDeletion: Boolean = false,
Copy link
Collaborator

@david-livefront david-livefront May 22, 2025

Choose a reason for hiding this comment

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

Can you make sure to add a doc for this and add an override for the test fixture as well

)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ fun createMockOrganization(
familySponsorshipValidUntil: ZonedDateTime? = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
status: OrganizationStatusType = OrganizationStatusType.ACCEPTED,
userIsClaimedByOrganization: Boolean = false,
limitItemDeletion: Boolean = false,
Copy link
Collaborator

Choose a reason for hiding this comment

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

๐Ÿ‘

): SyncResponseJson.Profile.Organization =
SyncResponseJson.Profile.Organization(
shouldUsePolicies = shouldUsePolicies,
Expand Down Expand Up @@ -126,6 +127,7 @@ fun createMockOrganization(
familySponsorshipValidUntil = familySponsorshipValidUntil,
status = status,
userIsClaimedByOrganization = userIsClaimedByOrganization,
limitItemDeletion = limitItemDeletion,
)

/**
Expand Down