1
0
mirror of https://github.com/bitwarden/server synced 2025-12-22 19:23:45 +00:00

[PM-23845] Update cache service to handle concurrency (#6170)

This commit is contained in:
Jimmy Vo
2025-09-12 13:44:19 -04:00
committed by GitHub
parent 4e64d35f89
commit 854abb0993
15 changed files with 1392 additions and 10 deletions

View File

@@ -0,0 +1,10 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public interface IApplicationCacheServiceBusMessaging
{
Task NotifyOrganizationAbilityUpsertedAsync(Organization organization);
Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId);
Task NotifyProviderAbilityDeletedAsync(Guid providerId);
}

View File

@@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Models.Data.Organizations;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public interface IVCurrentInMemoryApplicationCacheService
{
Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync();
#nullable enable
Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId);
#nullable disable
Task<IDictionary<Guid, ProviderAbility>> GetProviderAbilitiesAsync();
Task UpsertOrganizationAbilityAsync(Organization organization);
Task UpsertProviderAbilityAsync(Provider provider);
Task DeleteOrganizationAbilityAsync(Guid organizationId);
Task DeleteProviderAbilityAsync(Guid providerId);
}

View File

@@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Models.Data.Organizations;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public interface IVNextInMemoryApplicationCacheService
{
Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync();
#nullable enable
Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId);
#nullable disable
Task<IDictionary<Guid, ProviderAbility>> GetProviderAbilitiesAsync();
Task UpsertOrganizationAbilityAsync(Organization organization);
Task UpsertProviderAbilityAsync(Provider provider);
Task DeleteOrganizationAbilityAsync(Guid organizationId);
Task DeleteProviderAbilityAsync(Guid providerId);
}

View File

@@ -0,0 +1,21 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public class NoOpApplicationCacheMessaging : IApplicationCacheServiceBusMessaging
{
public Task NotifyOrganizationAbilityUpsertedAsync(Organization organization)
{
return Task.CompletedTask;
}
public Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId)
{
return Task.CompletedTask;
}
public Task NotifyProviderAbilityDeletedAsync(Guid providerId)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,63 @@
using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public class ServiceBusApplicationCacheMessaging : IApplicationCacheServiceBusMessaging
{
private readonly ServiceBusSender _topicMessageSender;
private readonly string _subName;
public ServiceBusApplicationCacheMessaging(
GlobalSettings globalSettings)
{
_subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings);
var serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString);
_topicMessageSender = serviceBusClient.CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName);
}
public async Task NotifyOrganizationAbilityUpsertedAsync(Organization organization)
{
var message = new ServiceBusMessage
{
Subject = _subName,
ApplicationProperties =
{
{ "type", (byte)ApplicationCacheMessageType.UpsertOrganizationAbility },
{ "id", organization.Id },
}
};
await _topicMessageSender.SendMessageAsync(message);
}
public async Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId)
{
var message = new ServiceBusMessage
{
Subject = _subName,
ApplicationProperties =
{
{ "type", (byte)ApplicationCacheMessageType.DeleteOrganizationAbility },
{ "id", organizationId },
}
};
await _topicMessageSender.SendMessageAsync(message);
}
public async Task NotifyProviderAbilityDeletedAsync(Guid providerId)
{
var message = new ServiceBusMessage
{
Subject = _subName,
ApplicationProperties =
{
{ "type", (byte)ApplicationCacheMessageType.DeleteProviderAbility },
{ "id", providerId },
}
};
await _topicMessageSender.SendMessageAsync(message);
}
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Concurrent;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public class VNextInMemoryApplicationCacheService(
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
TimeProvider timeProvider) : IVNextInMemoryApplicationCacheService
{
private ConcurrentDictionary<Guid, OrganizationAbility> _orgAbilities = new();
private readonly SemaphoreSlim _orgInitLock = new(1, 1);
private DateTimeOffset _lastOrgAbilityRefresh = DateTimeOffset.MinValue;
private ConcurrentDictionary<Guid, ProviderAbility> _providerAbilities = new();
private readonly SemaphoreSlim _providerInitLock = new(1, 1);
private DateTimeOffset _lastProviderAbilityRefresh = DateTimeOffset.MinValue;
private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(10);
public virtual async Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync()
{
await InitOrganizationAbilitiesAsync();
return _orgAbilities;
}
public async Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid organizationId)
{
(await GetOrganizationAbilitiesAsync())
.TryGetValue(organizationId, out var organizationAbility);
return organizationAbility;
}
public virtual async Task<IDictionary<Guid, ProviderAbility>> GetProviderAbilitiesAsync()
{
await InitProviderAbilitiesAsync();
return _providerAbilities;
}
public virtual async Task UpsertProviderAbilityAsync(Provider provider)
{
await InitProviderAbilitiesAsync();
_providerAbilities.AddOrUpdate(
provider.Id,
static (_, provider) => new ProviderAbility(provider),
static (_, _, provider) => new ProviderAbility(provider),
provider);
}
public virtual async Task UpsertOrganizationAbilityAsync(Organization organization)
{
await InitOrganizationAbilitiesAsync();
_orgAbilities.AddOrUpdate(
organization.Id,
static (_, organization) => new OrganizationAbility(organization),
static (_, _, organization) => new OrganizationAbility(organization),
organization);
}
public virtual Task DeleteOrganizationAbilityAsync(Guid organizationId)
{
_orgAbilities.TryRemove(organizationId, out _);
return Task.CompletedTask;
}
public virtual Task DeleteProviderAbilityAsync(Guid providerId)
{
_providerAbilities.TryRemove(providerId, out _);
return Task.CompletedTask;
}
private async Task InitOrganizationAbilitiesAsync() =>
await InitAbilitiesAsync<OrganizationAbility>(
dict => _orgAbilities = dict,
() => _lastOrgAbilityRefresh,
dt => _lastOrgAbilityRefresh = dt,
_orgInitLock,
async () => await organizationRepository.GetManyAbilitiesAsync(),
_refreshInterval,
ability => ability.Id);
private async Task InitProviderAbilitiesAsync() =>
await InitAbilitiesAsync<ProviderAbility>(
dict => _providerAbilities = dict,
() => _lastProviderAbilityRefresh,
dateTime => _lastProviderAbilityRefresh = dateTime,
_providerInitLock,
async () => await providerRepository.GetManyAbilitiesAsync(),
_refreshInterval,
ability => ability.Id);
private async Task InitAbilitiesAsync<TAbility>(
Action<ConcurrentDictionary<Guid, TAbility>> setCache,
Func<DateTimeOffset> getLastRefresh,
Action<DateTimeOffset> setLastRefresh,
SemaphoreSlim @lock,
Func<Task<IEnumerable<TAbility>>> fetchFunc,
TimeSpan refreshInterval,
Func<TAbility, Guid> getId)
{
if (SkipRefresh())
{
return;
}
await @lock.WaitAsync();
try
{
if (SkipRefresh())
{
return;
}
var sources = await fetchFunc();
var abilities = new ConcurrentDictionary<Guid, TAbility>(
sources.ToDictionary(getId));
setCache(abilities);
setLastRefresh(timeProvider.GetUtcNow());
}
finally
{
@lock.Release();
}
bool SkipRefresh()
{
return timeProvider.GetUtcNow() - getLastRefresh() <= refreshInterval;
}
}
}

View File

@@ -131,6 +131,8 @@ public static class FeatureFlagKeys
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service";
public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors";
public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command";

View File

@@ -3,6 +3,7 @@ using Azure.Messaging.ServiceBus.Administration;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Services.Implementations;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Hosting;
@@ -14,7 +15,7 @@ namespace Bit.Core.HostedServices;
public class ApplicationCacheHostedService : IHostedService, IDisposable
{
private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService;
private readonly FeatureRoutedCacheService? _applicationCacheService;
private readonly IOrganizationRepository _organizationRepository;
protected readonly ILogger<ApplicationCacheHostedService> _logger;
private readonly ServiceBusClient _serviceBusClient;
@@ -34,7 +35,7 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable
{
_topicName = globalSettings.ServiceBus.ApplicationCacheTopicName;
_subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings);
_applicationCacheService = applicationCacheService as InMemoryServiceBusApplicationCacheService;
_applicationCacheService = applicationCacheService as FeatureRoutedCacheService;
_organizationRepository = organizationRepository;
_logger = logger;
_serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString);

View File

@@ -0,0 +1,152 @@
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Models.Data.Organizations;
namespace Bit.Core.Services.Implementations;
/// <summary>
/// A feature-flagged routing service for application caching that bridges the gap between
/// scoped dependency injection (IFeatureService) and singleton services (cache implementations).
/// This service allows dynamic routing between IVCurrentInMemoryApplicationCacheService and
/// IVNextInMemoryApplicationCacheService based on the PM23845_VNextApplicationCache feature flag.
/// </summary>
/// <remarks>
/// This service is necessary because:
/// - IFeatureService is registered as Scoped in the DI container
/// - IVNextInMemoryApplicationCacheService and IVCurrentInMemoryApplicationCacheService are registered as Singleton
/// - We need to evaluate feature flags at request time while maintaining singleton cache behavior
///
/// The service acts as a scoped proxy that can access the scoped IFeatureService while
/// delegating actual cache operations to the appropriate singleton implementation.
/// </remarks>
public class FeatureRoutedCacheService(
IFeatureService featureService,
IVNextInMemoryApplicationCacheService vNextInMemoryApplicationCacheService,
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService,
IApplicationCacheServiceBusMessaging serviceBusMessaging)
: IApplicationCacheService
{
public async Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync()
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
return await vNextInMemoryApplicationCacheService.GetOrganizationAbilitiesAsync();
}
return await inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync();
}
public async Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
return await vNextInMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId);
}
return await inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId);
}
public async Task<IDictionary<Guid, ProviderAbility>> GetProviderAbilitiesAsync()
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
return await vNextInMemoryApplicationCacheService.GetProviderAbilitiesAsync();
}
return await inMemoryApplicationCacheService.GetProviderAbilitiesAsync();
}
public async Task UpsertOrganizationAbilityAsync(Organization organization)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
await serviceBusMessaging.NotifyOrganizationAbilityUpsertedAsync(organization);
}
else
{
await inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
public async Task UpsertProviderAbilityAsync(Provider provider)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
await vNextInMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider);
}
else
{
await inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider);
}
}
public async Task DeleteOrganizationAbilityAsync(Guid organizationId)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
await serviceBusMessaging.NotifyOrganizationAbilityDeletedAsync(organizationId);
}
else
{
await inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
}
}
public async Task DeleteProviderAbilityAsync(Guid providerId)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
await vNextInMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId);
await serviceBusMessaging.NotifyProviderAbilityDeletedAsync(providerId);
}
else
{
await inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId);
}
}
public async Task BaseUpsertOrganizationAbilityAsync(Organization organization)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
else
{
// NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation.
// Avoid using this approach in new code.
if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache)
{
await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization);
}
else
{
throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}");
}
}
}
public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache))
{
await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
}
else
{
// NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation.
// Avoid using this approach in new code.
if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache)
{
await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId);
}
else
{
throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}");
}
}
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
@@ -10,7 +11,7 @@ using Bit.Core.Repositories;
namespace Bit.Core.Services;
public class InMemoryApplicationCacheService : IApplicationCacheService
public class InMemoryApplicationCacheService : IVCurrentInMemoryApplicationCacheService
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderRepository _providerRepository;

View File

@@ -8,9 +8,8 @@ using Bit.Core.Utilities;
namespace Bit.Core.Services;
public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService, IApplicationCacheService
public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService
{
private readonly ServiceBusClient _serviceBusClient;
private readonly ServiceBusSender _topicMessageSender;
private readonly string _subName;
@@ -21,7 +20,7 @@ public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCach
: base(organizationRepository, providerRepository)
{
_subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings);
_serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString);
_topicMessageSender = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString).CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName);
}

View File

@@ -1,7 +1,9 @@
using System.Globalization;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Core.Services.Implementations;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
@@ -52,13 +54,18 @@ public class Startup
// Services
var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName);
services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
services.AddSingleton<IVNextInMemoryApplicationCacheService, VNextInMemoryApplicationCacheService>();
if (usingServiceBusAppCache)
{
services.AddSingleton<IApplicationCacheService, InMemoryServiceBusApplicationCacheService>();
services.AddSingleton<IVCurrentInMemoryApplicationCacheService, InMemoryServiceBusApplicationCacheService>();
services.AddSingleton<IApplicationCacheServiceBusMessaging, ServiceBusApplicationCacheMessaging>();
}
else
{
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
services.AddSingleton<IVCurrentInMemoryApplicationCacheService, InMemoryApplicationCacheService>();
services.AddSingleton<IApplicationCacheServiceBusMessaging, NoOpApplicationCacheMessaging>();
}
services.AddEventWriteServices(globalSettings);

View File

@@ -6,6 +6,7 @@ using System.Reflection;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using AspNetCoreRateLimit;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@@ -41,6 +42,7 @@ using Bit.Core.Resources;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
using Bit.Core.Services;
using Bit.Core.Services.Implementations;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Tools.ImportFeatures;
@@ -247,14 +249,19 @@ public static class ServiceCollectionExtensions
services.AddOptionality();
services.AddTokenizers();
services.AddSingleton<IVNextInMemoryApplicationCacheService, VNextInMemoryApplicationCacheService>();
services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName))
{
services.AddSingleton<IApplicationCacheService, InMemoryServiceBusApplicationCacheService>();
services.AddSingleton<IVCurrentInMemoryApplicationCacheService, InMemoryServiceBusApplicationCacheService>();
services.AddSingleton<IApplicationCacheServiceBusMessaging, ServiceBusApplicationCacheMessaging>();
}
else
{
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
services.AddSingleton<IVCurrentInMemoryApplicationCacheService, InMemoryApplicationCacheService>();
services.AddSingleton<IApplicationCacheServiceBusMessaging, NoOpApplicationCacheMessaging>();
}
var awsConfigured = CoreHelpers.SettingHasValue(globalSettings.Amazon?.AccessKeySecret);