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; }