Skip to content

Commit d9aef5c

Browse files
authored
[PM-32068] - Org Ability Extended Cache (#7443)
* Added extended cache implementation for org ability. * fixed up events * Moved utilities to service class. Moved tests to integration tests - with specific endpoints tested. moved registration to ext method
1 parent 901e86a commit d9aef5c

8 files changed

Lines changed: 386 additions & 13 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.Models.Data.Organizations;
3+
using Bit.Core.Repositories;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using ZiggyCreatures.Caching.Fusion;
6+
using static Bit.Core.AdminConsole.AbilitiesCache.ExtendedOrganizationAbilityCacheConstants;
7+
8+
namespace Bit.Core.AdminConsole.AbilitiesCache;
9+
10+
public static class ExtendedOrganizationAbilityCacheConstants
11+
{
12+
public const string CacheName = "OrganizationAbilities";
13+
}
14+
15+
public class ExtendedOrganizationAbilityCacheService(
16+
[FromKeyedServices(CacheName)] IFusionCache cache,
17+
IOrganizationRepository organizationRepository)
18+
: IOrganizationAbilityCacheService
19+
{
20+
21+
public async Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId)
22+
{
23+
var cacheKey = BuildCacheKeyForOrganizationAbility(orgId);
24+
return await cache.GetOrSetAsync<OrganizationAbility?>(
25+
cacheKey,
26+
async _ => await organizationRepository.GetAbilityAsync(orgId));
27+
}
28+
29+
public async Task UpsertOrganizationAbilityAsync(Organization organization)
30+
{
31+
var cacheKey = BuildCacheKeyForOrganizationAbility(organization.Id);
32+
await cache.SetAsync<OrganizationAbility?>(cacheKey, new OrganizationAbility(organization));
33+
}
34+
35+
public async Task DeleteOrganizationAbilityAsync(Guid organizationId)
36+
{
37+
var cacheKey = BuildCacheKeyForOrganizationAbility(organizationId);
38+
await cache.RemoveAsync(cacheKey);
39+
}
40+
41+
private static string BuildCacheKeyForOrganizationAbility(Guid organizationId)
42+
=> $"org-ability:{organizationId:N}";
43+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.Models.Data.Organizations;
3+
4+
namespace Bit.Core.AdminConsole.AbilitiesCache;
5+
6+
public interface IOrganizationAbilityCacheService
7+
{
8+
Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId);
9+
Task UpsertOrganizationAbilityAsync(Organization organization);
10+
Task DeleteOrganizationAbilityAsync(Guid organizationId);
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Bit.Core.Settings;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace Bit.Core.AdminConsole.AbilitiesCache;
5+
6+
public static class OrganizationAbilityServiceCollectionsExtension
7+
{
8+
public static IServiceCollection AddOrganizationAbilityCache(this IServiceCollection serviceCollection,
9+
GlobalSettings globalSettings) =>
10+
serviceCollection.AddExtendedCache(ExtendedOrganizationAbilityCacheConstants.CacheName, globalSettings)
11+
.AddScoped<IOrganizationAbilityCacheService, ExtendedOrganizationAbilityCacheService>();
12+
}

src/Core/Services/Implementations/FeatureRoutedCacheService.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@
77
namespace Bit.Core.Services.Implementations;
88

99
public class FeatureRoutedCacheService(
10-
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService)
10+
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService,
11+
IOrganizationAbilityCacheService extendedCacheService,
12+
IFeatureService featureService)
1113
: IApplicationCacheService
1214
{
1315
public Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync() =>
1416
inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync();
1517

1618
public Task<OrganizationAbility?> GetOrganizationAbilityAsync(Guid orgId) =>
17-
inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId);
19+
featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
20+
? extendedCacheService.GetOrganizationAbilityAsync(orgId)
21+
: inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId);
1822

1923
public Task<IDictionary<Guid, ProviderAbility>> GetProviderAbilitiesAsync() =>
2024
inMemoryApplicationCacheService.GetProviderAbilitiesAsync();
@@ -44,19 +48,29 @@ public async Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbiliti
4448
}
4549

4650
public Task UpsertOrganizationAbilityAsync(Organization organization) =>
47-
inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
51+
featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
52+
? extendedCacheService.UpsertOrganizationAbilityAsync(organization)
53+
: inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
4854

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

5258
public Task DeleteOrganizationAbilityAsync(Guid organizationId) =>
53-
inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
59+
featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
60+
? extendedCacheService.DeleteOrganizationAbilityAsync(organizationId)
61+
: inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
5462

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

5866
public async Task BaseUpsertOrganizationAbilityAsync(Organization organization)
5967
{
68+
if (featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache))
69+
{
70+
await extendedCacheService.UpsertOrganizationAbilityAsync(organization);
71+
return;
72+
}
73+
6074
if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache)
6175
{
6276
await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization);
@@ -69,6 +83,12 @@ public async Task BaseUpsertOrganizationAbilityAsync(Organization organization)
6983

7084
public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId)
7185
{
86+
if (featureService.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache))
87+
{
88+
await extendedCacheService.DeleteOrganizationAbilityAsync(organizationId);
89+
return;
90+
}
91+
7292
if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache)
7393
{
7494
await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId);

src/Events/Startup.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public void ConfigureServices(IServiceCollection services)
5656
var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
5757
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName);
5858
services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
59+
services.AddOrganizationAbilityCache(globalSettings);
5960

6061
if (usingServiceBusAppCache)
6162
{

src/SharedWeb/Utilities/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ public static void AddDefaultServices(this IServiceCollection services, GlobalSe
295295
services.AddTokenizers();
296296

297297
services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
298+
services.AddOrganizationAbilityCache(globalSettings);
298299

299300
if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&
300301
CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName))
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System.Net;
2+
using Bit.Api.AdminConsole.Models.Request.Organizations;
3+
using Bit.Api.Auth.Models.Request.Accounts;
4+
using Bit.Api.IntegrationTest.Factories;
5+
using Bit.Api.IntegrationTest.Helpers;
6+
using Bit.Core;
7+
using Bit.Core.AdminConsole.Entities;
8+
using Bit.Core.Billing.Enums;
9+
using Bit.Core.Enums;
10+
using Bit.Core.Services;
11+
using NSubstitute;
12+
using Xunit;
13+
14+
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
15+
16+
public class OrganizationAbilityCacheTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
17+
{
18+
private readonly HttpClient _client;
19+
private readonly ApiApplicationFactory _factory;
20+
private readonly LoginHelper _loginHelper;
21+
22+
private Organization _organization = null!;
23+
private string _ownerEmail = null!;
24+
25+
public OrganizationAbilityCacheTests(ApiApplicationFactory factory)
26+
{
27+
_factory = factory;
28+
_factory.SubstituteService<IFeatureService>(featureService =>
29+
{
30+
featureService
31+
.IsEnabled(FeatureFlagKeys.OrgAbilityExtendedCache)
32+
.Returns(true);
33+
});
34+
_client = factory.CreateClient();
35+
_loginHelper = new LoginHelper(_factory, _client);
36+
}
37+
38+
public async Task InitializeAsync()
39+
{
40+
_ownerEmail = $"cache-test-{Guid.NewGuid()}@example.com";
41+
await _factory.LoginWithNewAccount(_ownerEmail);
42+
43+
var result = await OrganizationTestHelpers.SignUpAsync(
44+
_factory,
45+
plan: PlanType.EnterpriseAnnually,
46+
ownerEmail: _ownerEmail,
47+
passwordManagerSeats: 5,
48+
paymentMethod: PaymentMethodType.Card);
49+
_organization = result.Item1;
50+
}
51+
52+
public Task DisposeAsync()
53+
{
54+
_client.Dispose();
55+
return Task.CompletedTask;
56+
}
57+
58+
[Fact]
59+
public async Task SignUp_PopulatesCache_GetOrganizationAbilityReturnsAbility()
60+
{
61+
// Arrange - organization already created in InitializeAsync via SignUpAsync,
62+
// which calls UpsertOrganizationAbilityAsync
63+
64+
// Act - read the cached ability directly
65+
var cacheService = _factory.GetService<IApplicationCacheService>();
66+
var ability = await cacheService.GetOrganizationAbilityAsync(_organization.Id);
67+
68+
// Assert - cache was populated by the sign-up flow
69+
Assert.NotNull(ability);
70+
Assert.Equal(_organization.Id, ability.Id);
71+
Assert.True(ability.Enabled);
72+
}
73+
74+
[Fact]
75+
public async Task Put_UpdatesOrganization_CacheReflectsUpdatedValues()
76+
{
77+
// Arrange - setup in InitializeAsync()
78+
await _loginHelper.LoginAsync(_ownerEmail);
79+
var updateRequest = new OrganizationUpdateRequestModel
80+
{
81+
Name = "Updated Cache Test Org",
82+
BillingEmail = "updated-cache@example.com"
83+
};
84+
85+
// Act - update the organization via the HTTP endpoint
86+
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);
87+
88+
// Assert - endpoint succeeded and cache was updated
89+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
90+
91+
var cacheService = _factory.GetService<IApplicationCacheService>();
92+
var ability = await cacheService.GetOrganizationAbilityAsync(_organization.Id);
93+
Assert.NotNull(ability);
94+
Assert.Equal(_organization.Id, ability.Id);
95+
}
96+
97+
[Fact]
98+
public async Task Delete_RemovesOrganization_CacheReturnsNull()
99+
{
100+
// Arrange - create a separate org for deletion so we don't affect other tests
101+
var deleteOwnerEmail = $"delete-test-{Guid.NewGuid()}@example.com";
102+
await _factory.LoginWithNewAccount(deleteOwnerEmail);
103+
104+
var signUpResult = await OrganizationTestHelpers.SignUpAsync(
105+
_factory,
106+
plan: PlanType.EnterpriseAnnually,
107+
ownerEmail: deleteOwnerEmail,
108+
passwordManagerSeats: 5,
109+
paymentMethod: PaymentMethodType.Card);
110+
var orgToDelete = signUpResult.Item1;
111+
112+
// Verify cache is populated before delete
113+
var cacheService = _factory.GetService<IApplicationCacheService>();
114+
var abilityBeforeDelete = await cacheService.GetOrganizationAbilityAsync(orgToDelete.Id);
115+
Assert.NotNull(abilityBeforeDelete);
116+
117+
// Act - delete the organization via the HTTP endpoint
118+
await _loginHelper.LoginAsync(deleteOwnerEmail);
119+
var deleteRequest = new SecretVerificationRequestModel
120+
{
121+
MasterPasswordHash = "master_password_hash"
122+
};
123+
var response = await _client.PostAsJsonAsync(
124+
$"/organizations/{orgToDelete.Id}/delete", deleteRequest);
125+
126+
// Assert - endpoint succeeded and cache was cleared
127+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
128+
129+
var abilityAfterDelete = await cacheService.GetOrganizationAbilityAsync(orgToDelete.Id);
130+
Assert.Null(abilityAfterDelete);
131+
}
132+
}

0 commit comments

Comments
 (0)