From 3b5bb76800e33222cb7581ccb9a4c36056ccd304 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Mon, 29 Dec 2025 09:30:22 -0800 Subject: [PATCH] [PM-28747] Storage limit bypass for enforce organization ownership policy (#6759) * [PM-28747] Bypass storage limit when enforce organization data ownership policy is enabled * [PM-28747] Unit tests for storage limit enforcement * [PM-28747] Add feature flag check * [PM-28747] Simplify ignore storage limits policy enforcement * [PM-28747] Add additional test cases --- ...anizationDataOwnershipPolicyRequirement.cs | 11 ++ .../Services/Implementations/CipherService.cs | 38 ++++- .../Vault/Services/CipherServiceTests.cs | 135 ++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index 28d6614dcb..c9653053ea 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -72,6 +72,17 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement { return _policyDetails.Any(p => p.OrganizationId == organizationId); } + + /// + /// Ignore storage limits if the organization has data ownership policy enabled. + /// Allows users to seamlessly migrate their data into the organization without being blocked by storage limits. + /// Organization admins will need to manage storage after migration should overages occur. + /// + public bool IgnoreStorageLimitsOnMigration(Guid organizationId) + { + return _policyDetails.Any(p => p.OrganizationId == organizationId && + p.OrganizationUserStatus == OrganizationUserStatusType.Confirmed); + } } public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index bb752b471f..797b595cbe 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -2,6 +2,7 @@ #nullable disable using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -999,20 +1000,43 @@ public class CipherService : ICipherService throw new BadRequestException("Could not find organization."); } - if (hasAttachments && !org.MaxStorageGb.HasValue) + if (!await IgnoreStorageLimitsOnMigrationAsync(sharingUserId, org)) { - throw new BadRequestException("This organization cannot use attachments."); - } + if (hasAttachments && !org.MaxStorageGb.HasValue) + { + throw new BadRequestException("This organization cannot use attachments."); + } - var storageAdjustment = attachments?.Sum(a => a.Value.Size) ?? 0; - if (org.StorageBytesRemaining() < storageAdjustment) - { - throw new BadRequestException("Not enough storage available for this organization."); + var storageAdjustment = attachments?.Sum(a => a.Value.Size) ?? 0; + if (org.StorageBytesRemaining() < storageAdjustment) + { + throw new BadRequestException("Not enough storage available for this organization."); + } } ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); } + /// + /// Checks if the storage limit for the org should be ignored due to the Organization Data Ownership Policy + /// + private async Task IgnoreStorageLimitsOnMigrationAsync(Guid userId, Organization organization) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems)) + { + return false; + } + + if (!organization.UsePolicies) + { + return false; + } + + var requirement = await _policyRequirementQuery.GetAsync(userId); + + return requirement.IgnoreStorageLimitsOnMigration(organization.Id); + } + private async Task ValidateViewPasswordUserAsync(Cipher cipher) { if (cipher.Data == null || !cipher.OrganizationId.HasValue) diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index fc84651951..058c6f68ab 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1190,6 +1190,7 @@ public class CipherServiceTests sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { + UsePolicies = true, PlanType = PlanType.EnterpriseAnnually, MaxStorageGb = 100 }); @@ -1206,6 +1207,140 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + [Theory, BitAutoData] + public async Task ShareManyAsync_StorageLimitBypass_Passes(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually, + UsePolicies = true, + MaxStorageGb = 3, + Storage = 3221225472 // 3 GB used, so 0 remaining + }); + ciphers.FirstOrDefault().Attachments = + "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; + + var cipherInfos = ciphers.Select(c => (c, + (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems).Returns(true); + + sutProvider.GetDependency() + .GetAsync(sharingUserId) + .Returns(new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + [new PolicyDetails + { + OrganizationId = organizationId, + PolicyType = PolicyType.OrganizationDataOwnership, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + }])); + + await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId); + await sutProvider.GetDependency().Received(1).UpdateCiphersAsync(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + + [Theory, BitAutoData] + public async Task ShareManyAsync_StorageLimit_Enforced(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually, + UsePolicies = true, + MaxStorageGb = 3, + Storage = 3221225472 // 3 GB used, so 0 remaining + }); + ciphers.FirstOrDefault().Attachments = + "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; + + var cipherInfos = ciphers.Select(c => (c, + (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + sutProvider.GetDependency() + .GetAsync(sharingUserId) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId) + ); + Assert.Contains("Not enough storage available for this organization.", exception.Message); + await sutProvider.GetDependency().DidNotReceive().UpdateCiphersAsync(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + + [Theory, BitAutoData] + public async Task ShareManyAsync_StorageLimit_Enforced_WhenFeatureFlagDisabled(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually, + UsePolicies = true, + MaxStorageGb = 3, + Storage = 3221225472 // 3 GB used, so 0 remaining + }); + ciphers.FirstOrDefault().Attachments = + "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; + + var cipherInfos = ciphers.Select(c => (c, + (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems).Returns(false); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId) + ); + Assert.Contains("Not enough storage available for this organization.", exception.Message); + await sutProvider.GetDependency().DidNotReceive().UpdateCiphersAsync(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + + [Theory, BitAutoData] + public async Task ShareManyAsync_StorageLimit_Enforced_WhenUsePoliciesDisabled(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually, + UsePolicies = false, + MaxStorageGb = 3, + Storage = 3221225472 // 3 GB used, so 0 remaining + }); + ciphers.FirstOrDefault().Attachments = + "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; + + var cipherInfos = ciphers.Select(c => (c, + (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems).Returns(true); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId) + ); + Assert.Contains("Not enough storage available for this organization.", exception.Message); + await sutProvider.GetDependency().DidNotReceive().UpdateCiphersAsync(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + private class SaveDetailsAsyncDependencies { public CipherDetails CipherDetails { get; set; }