Skip to content
Open
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
@@ -0,0 +1,43 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
using static Bit.Core.AdminConsole.AbilitiesCache.ExtendedOrganizationAbilityCacheConstants;

namespace Bit.Core.AdminConsole.AbilitiesCache;

public static class ExtendedOrganizationAbilityCacheConstants
{
public const string CacheName = "OrganizationAbilities";
}

public class ExtendedOrganizationAbilityCacheService(
[FromKeyedServices(CacheName)] IFusionCache cache,
IOrganizationRepository organizationRepository)
: IOrganizationAbilityCacheService
{

public async Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId)
{
var cacheKey = BuildCacheKeyForOrganizationAbility(orgId);
return await cache.GetOrSetAsync<OrganizationAbility?>(
cacheKey,
async _ => await organizationRepository.GetAbilityAsync(orgId));
}

public async Task UpsertOrganizationAbilityAsync(Organization organization)
{
var cacheKey = BuildCacheKeyForOrganizationAbility(organization.Id);
await cache.SetAsync<OrganizationAbility?>(cacheKey, new OrganizationAbility(organization));
}

public async Task DeleteOrganizationAbilityAsync(Guid organizationId)
{
var cacheKey = BuildCacheKeyForOrganizationAbility(organizationId);
await cache.RemoveAsync(cacheKey);
}

private static string BuildCacheKeyForOrganizationAbility(Guid organizationId)
=> $"org-ability:{organizationId:N}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data.Organizations;

namespace Bit.Core.AdminConsole.AbilitiesCache;

public interface IOrganizationAbilityCacheService
{
Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId);
Task UpsertOrganizationAbilityAsync(Organization organization);
Task DeleteOrganizationAbilityAsync(Guid organizationId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώusing Bit.Core.Settings;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.AdminConsole.AbilitiesCache;

public static class OrganizationAbilityServiceCollectionsExtension
{
public static IServiceCollection AddOrganizationAbilityCache(this IServiceCollection serviceCollection,
GlobalSettings globalSettings) =>
serviceCollection.AddExtendedCache(ExtendedOrganizationAbilityCacheConstants.CacheName, globalSettings)
.AddScoped<IOrganizationAbilityCacheService, ExtendedOrganizationAbilityCacheService>();
}
28 changes: 24 additions & 4 deletions src/Core/Services/Implementations/FeatureRoutedCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@
namespace Bit.Core.Services.Implementations;

public class FeatureRoutedCacheService(
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService)
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService,
IOrganizationAbilityCacheService extendedCacheService,
IFeatureService featureService)
: IApplicationCacheService
{
public Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync() =>
inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync();

public Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId) =>
inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId);
featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
? extendedCacheService.GetOrganizationAbilityAsync(orgId)
: inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId);

public Task<IDictionary<Guid, ProviderAbility>> GetProviderAbilitiesAsync() =>
inMemoryApplicationCacheService.GetProviderAbilitiesAsync();
Expand Down Expand Up @@ -44,19 +48,29 @@ public async Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbiliti
}

public Task UpsertOrganizationAbilityAsync(Organization organization) =>
inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
? extendedCacheService.UpsertOrganizationAbilityAsync(organization)
: inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);

public Task UpsertProviderAbilityAsync(Provider provider) =>
inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider);

public Task DeleteOrganizationAbilityAsync(Guid organizationId) =>
inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
? extendedCacheService.DeleteOrganizationAbilityAsync(organizationId)
: inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);

public Task DeleteProviderAbilityAsync(Guid providerId) =>
inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId);

public async Task BaseUpsertOrganizationAbilityAsync(Organization organization)
{
if (featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache))
{
await extendedCacheService.UpsertOrganizationAbilityAsync(organization);
return;
}

if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache)
{
await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization);
Expand All @@ -69,6 +83,12 @@ public async Task BaseUpsertOrganizationAbilityAsync(Organization organization)

public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId)
{
if (featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache))
{
await extendedCacheService.DeleteOrganizationAbilityAsync(organizationId);
return;
}

if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache)
{
await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId);
Expand Down
1 change: 1 addition & 0 deletions src/Events/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public void ConfigureServices(IServiceCollection services)
var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName);
services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
services.AddOrganizationAbilityCache(globalSettings);

if (usingServiceBusAppCache)
{
Expand Down
1 change: 1 addition & 0 deletions src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ public static void AddDefaultServices(this IServiceCollection services, GlobalSe
services.AddTokenizers();

services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
services.AddOrganizationAbilityCache(globalSettings);

Copy link
Copy Markdown
Member

@eliykat eliykat Apr 14, 2026

Choose a reason for hiding this comment

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

It would be better not to expose this at the top level here. Can we have a services extension method - e.g. AddOrganizationAbilityCache - which registers both? Similar to how our commands/queries/handlers are registered.

if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
ο»Ώusing System.Net;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Services;
using NSubstitute;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;

public class OrganizationAbilityCacheTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;

private Organization _organization = null!;
private string _ownerEmail = null!;

public OrganizationAbilityCacheTests(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
.Returns(true);
});
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

public async Task InitializeAsync()
{
_ownerEmail = $"cache-test-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(_ownerEmail);

var result = await OrganizationTestHelpers.SignUpAsync(
_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail,
passwordManagerSeats: 5,
paymentMethod: PaymentMethodType.Card);
_organization = result.Item1;
}

public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}

[Fact]
public async Task SignUp_PopulatesCache_GetOrganizationAbilityReturnsAbility()
{
// Arrange - organization already created in InitializeAsync via SignUpAsync,
// which calls UpsertOrganizationAbilityAsync

// Act - read the cached ability directly
var cacheService = _factory.GetService<IApplicationCacheService>();
var ability = await cacheService.GetOrganizationAbilityAsync(_organization.Id);

// Assert - cache was populated by the sign-up flow
Assert.NotNull(ability);
Assert.Equal(_organization.Id, ability.Id);
Assert.True(ability.Enabled);
}

[Fact]
public async Task Put_UpdatesOrganization_CacheReflectsUpdatedValues()
{
// Arrange - setup in InitializeAsync()
await _loginHelper.LoginAsync(_ownerEmail);
var updateRequest = new OrganizationUpdateRequestModel
{
Name = "Updated Cache Test Org",
BillingEmail = "updated-cache@example.com"
};

// Act - update the organization via the HTTP endpoint
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);

// Assert - endpoint succeeded and cache was updated
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var cacheService = _factory.GetService<IApplicationCacheService>();
var ability = await cacheService.GetOrganizationAbilityAsync(_organization.Id);
Assert.NotNull(ability);
Assert.Equal(_organization.Id, ability.Id);
}

[Fact]
public async Task Delete_RemovesOrganization_CacheReturnsNull()
{
// Arrange - create a separate org for deletion so we don't affect other tests
var deleteOwnerEmail = $"delete-test-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(deleteOwnerEmail);

var signUpResult = await OrganizationTestHelpers.SignUpAsync(
_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: deleteOwnerEmail,
passwordManagerSeats: 5,
paymentMethod: PaymentMethodType.Card);
var orgToDelete = signUpResult.Item1;

// Verify cache is populated before delete
var cacheService = _factory.GetService<IApplicationCacheService>();
var abilityBeforeDelete = await cacheService.GetOrganizationAbilityAsync(orgToDelete.Id);
Assert.NotNull(abilityBeforeDelete);

// Act - delete the organization via the HTTP endpoint
await _loginHelper.LoginAsync(deleteOwnerEmail);
var deleteRequest = new SecretVerificationRequestModel
{
MasterPasswordHash = "master_password_hash"
};
var response = await _client.PostAsJsonAsync(
$"/organizations/{orgToDelete.Id}/delete", deleteRequest);

// Assert - endpoint succeeded and cache was cleared
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var abilityAfterDelete = await cacheService.GetOrganizationAbilityAsync(orgToDelete.Id);
Assert.Null(abilityAfterDelete);
}
}
Loading
Loading