1
0
mirror of https://github.com/bitwarden/server synced 2026-01-28 15:23:38 +00:00

Merge branch 'main' into PM-30920-Server-changes-to-encrypt-send-access-email-list

This commit is contained in:
John Harrington
2026-01-22 10:32:32 -07:00
committed by GitHub
5 changed files with 338 additions and 14 deletions

View File

@@ -143,6 +143,7 @@ public static class FeatureFlagKeys
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -27,6 +28,7 @@ public class SendValidationService : ISendValidationService
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
@@ -38,7 +40,7 @@ public class SendValidationService : ISendValidationService
IUserService userService,
IPolicyRequirementQuery policyRequirementQuery,
GlobalSettings globalSettings,
IPricingClient pricingClient,
ICurrentContext currentContext)
{
_userRepository = userRepository;
@@ -48,6 +50,7 @@ public class SendValidationService : ISendValidationService
_userService = userService;
_policyRequirementQuery = policyRequirementQuery;
_globalSettings = globalSettings;
_pricingClient = pricingClient;
_currentContext = currentContext;
}
@@ -123,10 +126,19 @@ public class SendValidationService : ISendValidationService
}
else
{
// Users that get access to file storage/premium from their organization get the default
// 1 GB max storage.
short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1;
storageBytesRemaining = user.StorageBytesRemaining(limit);
// Users that get access to file storage/premium from their organization get storage
// based on the current premium plan from the pricing service
short provided;
if (_globalSettings.SelfHosted)
{
provided = Constants.SelfHostedMaxStorageGb;
}
else
{
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
provided = (short)premiumPlan.Storage.Provided;
}
storageBytesRemaining = user.StorageBytesRemaining(provided);
}
}
else if (send.OrganizationId.HasValue)

View File

@@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
@@ -46,6 +47,7 @@ public class CipherService : ICipherService
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
public CipherService(
ICipherRepository cipherRepository,
@@ -65,7 +67,8 @@ public class CipherService : ICipherService
IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery,
IPolicyRequirementQuery policyRequirementQuery,
IApplicationCacheService applicationCacheService,
IFeatureService featureService)
IFeatureService featureService,
IPricingClient pricingClient)
{
_cipherRepository = cipherRepository;
_folderRepository = folderRepository;
@@ -85,6 +88,7 @@ public class CipherService : ICipherService
_policyRequirementQuery = policyRequirementQuery;
_applicationCacheService = applicationCacheService;
_featureService = featureService;
_pricingClient = pricingClient;
}
public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate,
@@ -943,10 +947,19 @@ public class CipherService : ICipherService
}
else
{
// Users that get access to file storage/premium from their organization get the default
// 1 GB max storage.
storageBytesRemaining = user.StorageBytesRemaining(
_globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1);
// Users that get access to file storage/premium from their organization get storage
// based on the current premium plan from the pricing service
short provided;
if (_globalSettings.SelfHosted)
{
provided = Constants.SelfHostedMaxStorageGb;
}
else
{
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
provided = (short)premiumPlan.Storage.Provided;
}
storageBytesRemaining = user.StorageBytesRemaining(provided);
}
}
else if (cipher.OrganizationId.HasValue)

View File

@@ -0,0 +1,120 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Pricing.Premium;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Tools.Services;
[SutProviderCustomize]
public class SendValidationServiceTests
{
[Theory, BitAutoData]
public async Task StorageRemainingForSendAsync_OrgGrantedPremiumUser_UsesPricingService(
SutProvider<SendValidationService> sutProvider,
Send send,
User user)
{
// Arrange
send.UserId = user.Id;
send.OrganizationId = null;
send.Type = SendType.File;
user.Premium = false;
user.Storage = 1024L * 1024L * 1024L; // 1 GB used
user.EmailVerified = true;
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IUserService>().CanAccessPremium(user).Returns(true);
var premiumPlan = new Plan
{
Storage = new Purchasable { Provided = 5 }
};
sutProvider.GetDependency<IPricingClient>().GetAvailablePremiumPlan().Returns(premiumPlan);
// Act
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
// Assert
await sutProvider.GetDependency<IPricingClient>().Received(1).GetAvailablePremiumPlan();
Assert.True(result > 0);
}
[Theory, BitAutoData]
public async Task StorageRemainingForSendAsync_IndividualPremium_DoesNotCallPricingService(
SutProvider<SendValidationService> sutProvider,
Send send,
User user)
{
// Arrange
send.UserId = user.Id;
send.OrganizationId = null;
send.Type = SendType.File;
user.Premium = true;
user.MaxStorageGb = 10;
user.EmailVerified = true;
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IUserService>().CanAccessPremium(user).Returns(true);
// Act
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
// Assert - should NOT call pricing service for individual premium users
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
}
[Theory, BitAutoData]
public async Task StorageRemainingForSendAsync_SelfHosted_DoesNotCallPricingService(
SutProvider<SendValidationService> sutProvider,
Send send,
User user)
{
// Arrange
send.UserId = user.Id;
send.OrganizationId = null;
send.Type = SendType.File;
user.Premium = false;
user.EmailVerified = true;
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = true;
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IUserService>().CanAccessPremium(user).Returns(true);
// Act
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
// Assert - should NOT call pricing service for self-hosted
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
}
[Theory, BitAutoData]
public async Task StorageRemainingForSendAsync_OrgSend_DoesNotCallPricingService(
SutProvider<SendValidationService> sutProvider,
Send send,
Organization org)
{
// Arrange
send.UserId = null;
send.OrganizationId = org.Id;
send.Type = SendType.File;
org.MaxStorageGb = 100;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
// Act
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
// Assert - should NOT call pricing service for org sends
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
}
}

View File

@@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Pricing.Premium;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -2228,10 +2230,6 @@ public class CipherServiceTests
.PushSyncCiphersAsync(deletingUserId);
}
[Theory]
[OrganizationCipherCustomize]
[BitAutoData]
@@ -2387,6 +2385,186 @@ public class CipherServiceTests
ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id))));
}
[Theory, BitAutoData]
public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_UsesStorageFromPricingClient(
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
{
var stream = new MemoryStream(new byte[100]);
var fileName = "test.txt";
var key = "test-key";
// Setup cipher with user ownership
cipher.UserId = savingUserId;
cipher.OrganizationId = null;
// Setup user WITHOUT personal premium (Premium = false), but with org-granted premium access
var user = new User
{
Id = savingUserId,
Premium = false, // User does not have personal premium
MaxStorageGb = null, // No personal storage allocation
Storage = 0 // No storage used yet
};
sutProvider.GetDependency<IUserRepository>()
.GetByIdAsync(savingUserId)
.Returns(user);
// User has premium access through their organization
sutProvider.GetDependency<IUserService>()
.CanAccessPremium(user)
.Returns(true);
// Mock GlobalSettings to indicate cloud (not self-hosted)
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = false;
// Mock the PricingClient to return a premium plan with 1 GB of storage
var premiumPlan = new Plan
{
Name = "Premium",
Available = true,
Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 },
Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 }
};
sutProvider.GetDependency<IPricingClient>()
.GetAvailablePremiumPlan()
.Returns(premiumPlan);
sutProvider.GetDependency<IAttachmentStorageService>()
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<IAttachmentStorageService>()
.ValidateFileAsync(cipher, Arg.Any<CipherAttachment.MetaData>(), Arg.Any<long>())
.Returns((true, 100L));
sutProvider.GetDependency<ICipherRepository>()
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<ICipherRepository>()
.ReplaceAsync(Arg.Any<CipherDetails>())
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate);
// Assert - PricingClient was called to get the premium plan storage
await sutProvider.GetDependency<IPricingClient>().Received(1).GetAvailablePremiumPlan();
// Assert - Attachment was uploaded successfully
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>());
}
[Theory, BitAutoData]
public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_ExceedsStorage_ThrowsBadRequest(
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
{
var stream = new MemoryStream(new byte[100]);
var fileName = "test.txt";
var key = "test-key";
// Setup cipher with user ownership
cipher.UserId = savingUserId;
cipher.OrganizationId = null;
// Setup user WITHOUT personal premium, with org-granted access, but storage is full
var user = new User
{
Id = savingUserId,
Premium = false,
MaxStorageGb = null,
Storage = 1073741824 // 1 GB already used (equals the provided storage)
};
sutProvider.GetDependency<IUserRepository>()
.GetByIdAsync(savingUserId)
.Returns(user);
sutProvider.GetDependency<IUserService>()
.CanAccessPremium(user)
.Returns(true);
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = false;
// Premium plan provides 1 GB of storage
var premiumPlan = new Plan
{
Name = "Premium",
Available = true,
Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 },
Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 }
};
sutProvider.GetDependency<IPricingClient>()
.GetAvailablePremiumPlan()
.Returns(premiumPlan);
// Act & Assert - Should throw because storage is full
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate));
Assert.Contains("Not enough storage available", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_SelfHosted_UsesConstantStorage(
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
{
var stream = new MemoryStream(new byte[100]);
var fileName = "test.txt";
var key = "test-key";
// Setup cipher with user ownership
cipher.UserId = savingUserId;
cipher.OrganizationId = null;
// Setup user WITHOUT personal premium, but with org-granted premium access
var user = new User
{
Id = savingUserId,
Premium = false,
MaxStorageGb = null,
Storage = 0
};
sutProvider.GetDependency<IUserRepository>()
.GetByIdAsync(savingUserId)
.Returns(user);
sutProvider.GetDependency<IUserService>()
.CanAccessPremium(user)
.Returns(true);
// Mock GlobalSettings to indicate self-hosted
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = true;
sutProvider.GetDependency<IAttachmentStorageService>()
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<IAttachmentStorageService>()
.ValidateFileAsync(cipher, Arg.Any<CipherAttachment.MetaData>(), Arg.Any<long>())
.Returns((true, 100L));
sutProvider.GetDependency<ICipherRepository>()
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<ICipherRepository>()
.ReplaceAsync(Arg.Any<CipherDetails>())
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate);
// Assert - PricingClient should NOT be called for self-hosted
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
// Assert - Attachment was uploaded successfully
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>());
}
private async Task AssertNoActionsAsync(SutProvider<CipherService> sutProvider)
{
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default);