From 9511c26683534e63d7705d79e91be739c05b4d83 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 5 May 2025 10:29:50 +0000 Subject: [PATCH 1/7] Bumped version to 2025.5.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 16d8d83ae0..60d61e5e26 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.4.3 + 2025.5.0 Bit.$(MSBuildProjectName) enable From 75a2da3c4bd4debb45be28b7d50743a3726c25c6 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 5 May 2025 08:04:59 -0400 Subject: [PATCH 2/7] [PM-17562] Add support for extended properties on event integrations (#5755) * [PM-17562] Add support for extended properties on event integrations * Clean up IntegrationEventHandlerBase * Respond to PR feedback --- .../IntegrationTemplateContext.cs | 37 +++ .../IntegrationEventHandlerBase.cs | 66 ++++++ .../Implementations/SlackEventHandler.cs | 47 ++-- .../Implementations/WebhookEventHandler.cs | 47 ++-- .../Utilities/IntegrationTemplateProcessor.cs | 35 ++- .../IntegrationEventHandlerBaseTests.cs | 219 ++++++++++++++++++ .../IntegrationTemplateProcessorTests.cs | 57 +++++ 7 files changed, 445 insertions(+), 63 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs create mode 100644 test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs new file mode 100644 index 0000000000..18aa3b7681 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; + +#nullable enable + +namespace Bit.Core.Models.Data.Integrations; + +public class IntegrationTemplateContext(EventMessage eventMessage) +{ + public EventMessage Event { get; } = eventMessage; + + public string DomainName => Event.DomainName; + public string IpAddress => Event.IpAddress; + public DeviceType? DeviceType => Event.DeviceType; + public Guid? ActingUserId => Event.ActingUserId; + public Guid? OrganizationUserId => Event.OrganizationUserId; + public DateTime Date => Event.Date; + public EventType Type => Event.Type; + public Guid? UserId => Event.UserId; + public Guid? OrganizationId => Event.OrganizationId; + public Guid? CipherId => Event.CipherId; + public Guid? CollectionId => Event.CollectionId; + public Guid? GroupId => Event.GroupId; + public Guid? PolicyId => Event.PolicyId; + + public User? User { get; set; } + public string? UserName => User?.Name; + public string? UserEmail => User?.Email; + + public User? ActingUser { get; set; } + public string? ActingUserName => ActingUser?.Name; + public string? ActingUserEmail => ActingUser?.Email; + + public Organization? Organization { get; set; } + public string? OrganizationName => Organization?.DisplayName(); +} diff --git a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs new file mode 100644 index 0000000000..d8e521de97 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; + +namespace Bit.Core.Services; + +public abstract class IntegrationEventHandlerBase( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IEventMessageHandler +{ + public async Task HandleEventAsync(EventMessage eventMessage) + { + var organizationId = eventMessage.OrganizationId ?? Guid.Empty; + var configurations = await configurationRepository.GetConfigurationDetailsAsync( + organizationId, + GetIntegrationType(), + eventMessage.Type); + + foreach (var configuration in configurations) + { + var context = await BuildContextAsync(eventMessage, configuration.Template); + var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); + + await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); + } + } + + public async Task HandleManyEventsAsync(IEnumerable eventMessages) + { + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } + } + + private async Task BuildContextAsync(EventMessage eventMessage, string template) + { + var context = new IntegrationTemplateContext(eventMessage); + + if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) + { + context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) + { + context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) + { + context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); + } + + return context; + } + + protected abstract IntegrationType GetIntegrationType(); + + protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs index c81914b708..3ddecc67f4 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs @@ -1,46 +1,35 @@ using System.Text.Json; -using Bit.Core.AdminConsole.Utilities; +using System.Text.Json.Nodes; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; +#nullable enable + namespace Bit.Core.Services; public class SlackEventHandler( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, IOrganizationIntegrationConfigurationRepository configurationRepository, ISlackService slackService) - : IEventMessageHandler + : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) { - public async Task HandleEventAsync(EventMessage eventMessage) + protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; + + protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, + string renderedTemplate) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - IntegrationType.Slack, - eventMessage.Type); - - foreach (var configuration in configurations) + var config = mergedConfiguration.Deserialize(); + if (config is null) { - var config = configuration.MergedConfiguration.Deserialize(); - if (config is null) - { - continue; - } - - await slackService.SendSlackMessageByChannelIdAsync( - config.token, - IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), - config.channelId - ); + return; } - } - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } + await slackService.SendSlackMessageByChannelIdAsync( + config.token, + renderedTemplate, + config.channelId + ); } } diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs index 1c3b279ee5..ec6924bb3e 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs @@ -1,8 +1,7 @@ using System.Text; using System.Text.Json; -using Bit.Core.AdminConsole.Utilities; +using System.Text.Json.Nodes; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; @@ -12,46 +11,28 @@ namespace Bit.Core.Services; public class WebhookEventHandler( IHttpClientFactory httpClientFactory, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, IOrganizationIntegrationConfigurationRepository configurationRepository) - : IEventMessageHandler + : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); public const string HttpClientName = "WebhookEventHandlerHttpClient"; - public async Task HandleEventAsync(EventMessage eventMessage) + protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; + + protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, + string renderedTemplate) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - IntegrationType.Webhook, - eventMessage.Type); - - foreach (var configuration in configurations) + var config = mergedConfiguration.Deserialize(); + if (config is null || string.IsNullOrEmpty(config.url)) { - var config = configuration.MergedConfiguration.Deserialize(); - if (config is null || string.IsNullOrEmpty(config.url)) - { - continue; - } - - var content = new StringContent( - IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), - Encoding.UTF8, - "application/json" - ); - var response = await _httpClient.PostAsync( - config.url, - content); - response.EnsureSuccessStatusCode(); + return; } - } - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } + var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(config.url, content); + response.EnsureSuccessStatusCode(); } } diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index 178c0348d9..4fb5c15e63 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -10,8 +10,9 @@ public static partial class IntegrationTemplateProcessor public static string ReplaceTokens(string template, object values) { if (string.IsNullOrEmpty(template) || values == null) + { return template; - + } var type = values.GetType(); return TokenRegex().Replace(template, match => { @@ -20,4 +21,36 @@ public static partial class IntegrationTemplateProcessor return property?.GetValue(values)?.ToString() ?? match.Value; }); } + + public static bool TemplateRequiresUser(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#UserName#", StringComparison.Ordinal) + || template.Contains("#UserEmail#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresActingUser(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#ActingUserName#", StringComparison.Ordinal) + || template.Contains("#ActingUserEmail#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresOrganization(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#OrganizationName#", StringComparison.Ordinal); + } } diff --git a/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs new file mode 100644 index 0000000000..e1a2fbff68 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class IntegrationEventHandlerBaseEventHandlerTests +{ + private const string _templateBase = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + private const string _templateWithOrganization = "Org: #OrganizationName#"; + private const string _templateWithUser = "#UserName#, #UserEmail#"; + private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#"; + private const string _url = "https://localhost"; + + private SutProvider GetSutProvider( + List configurations) + { + var configurationRepository = Substitute.For(); + configurationRepository.GetConfigurationDetailsAsync(Arg.Any(), + IntegrationType.Webhook, Arg.Any()).Returns(configurations); + + return new SutProvider() + .SetDependency(configurationRepository) + .Create(); + } + + private static List NoConfigurations() + { + return []; + } + + private static List OneConfiguration(string template) + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config.Template = template; + + return [config]; + } + + private static List TwoConfigurations(string template) + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config.Template = template; + var config2 = Substitute.For(); + config2.Configuration = null; + config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config2.Template = template; + + return [config, config2]; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + Assert.Empty(sutProvider.Sut.CapturedCalls); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; + + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var user = Substitute.For(); + user.Email = "test@example.com"; + user.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"{user.Name}, {user.Email}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.ActingUserId ?? Guid.Empty); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); + var organization = Substitute.For(); + organization.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(organization); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"Org: {organization.Name}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var user = Substitute.For(); + user.Email = "test@example.com"; + user.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"{user.Name}, {user.Email}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.UserId ?? Guid.Empty); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List eventMessages) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + Assert.Empty(sutProvider.Sut.CapturedCalls); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + Assert.Equal(eventMessages.Count, sutProvider.Sut.CapturedCalls.Count); + var index = 0; + foreach (var call in sutProvider.Sut.CapturedCalls) + { + var expected = eventMessages[index]; + var expectedTemplate = $"Date: {expected.Date}, Type: {expected.Type}, UserId: {expected.UserId}"; + + Assert.Equal(expectedTemplate, call.RenderedTemplate); + index++; + } + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_CallsProcessEventIntegrationAsyncMultipleTimes( + List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + Assert.Equal(eventMessages.Count * 2, sutProvider.Sut.CapturedCalls.Count); + + var capturedCalls = sutProvider.Sut.CapturedCalls.GetEnumerator(); + foreach (var eventMessage in eventMessages) + { + var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; + + Assert.True(capturedCalls.MoveNext()); + var call = capturedCalls.Current; + Assert.Equal(expectedTemplate, call.RenderedTemplate); + + Assert.True(capturedCalls.MoveNext()); + call = capturedCalls.Current; + Assert.Equal(expectedTemplate, call.RenderedTemplate); + } + } + + private class TestIntegrationEventHandlerBase : IntegrationEventHandlerBase + { + public TestIntegrationEventHandlerBase(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : base(userRepository, organizationRepository, configurationRepository) + { } + + public List<(JsonObject MergedConfiguration, string RenderedTemplate)> CapturedCalls { get; } = new(); + + protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; + + protected override Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate) + { + CapturedCalls.Add((mergedConfiguration, renderedTemplate)); + return Task.CompletedTask; + } + } +} diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs index 9ab3b592cb..d117b5e999 100644 --- a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -89,4 +89,61 @@ public class IntegrationTemplateProcessorTests Assert.Equal(expected, result); } + + [Theory] + [InlineData("User name is #UserName#")] + [InlineData("Email: #UserEmail#")] + public void TemplateRequiresUser_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresUser(template); + Assert.True(result); + } + + [Theory] + [InlineData("#UserId#")] // This is on the base class, not fetched, so should be false + [InlineData("No User Tokens")] + [InlineData("")] + public void TemplateRequiresUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresUser(template); + Assert.False(result); + } + + [Theory] + [InlineData("Acting user is #ActingUserName#")] + [InlineData("Acting user's email is #ActingUserEmail#")] + public void TemplateRequiresActingUser_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template); + Assert.True(result); + } + + [Theory] + [InlineData("No ActiveUser tokens")] + [InlineData("#ActiveUserId#")] // This is on the base class, not fetched, so should be false + [InlineData("")] + public void TemplateRequiresActingUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template); + Assert.False(result); + } + + [Theory] + [InlineData("Organization: #OrganizationName#")] + [InlineData("Welcome to #OrganizationName#")] + public void TemplateRequiresOrganization_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template); + Assert.True(result); + } + + [Theory] + [InlineData("No organization tokens")] + [InlineData("#OrganizationId#")] // This is on the base class, not fetched, so should be false + [InlineData("")] + public void TemplateRequiresOrganization_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template); + Assert.False(result); + } } From 4b49b04409bd255b832c30971f368d25408d23d6 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 5 May 2025 08:05:38 -0400 Subject: [PATCH 3/7] [PM-17562] Revert event route optimization (#5766) --- .../Implementations/EventRouteService.cs | 34 ++++++++++ src/Events/Startup.cs | 9 +-- .../Utilities/ServiceCollectionExtensions.cs | 9 +-- .../Services/EventRouteServiceTests.cs | 65 +++++++++++++++++++ 4 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 src/Core/AdminConsole/Services/Implementations/EventRouteService.cs create mode 100644 test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs diff --git a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs new file mode 100644 index 0000000000..a542e75a7b --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs @@ -0,0 +1,34 @@ +using Bit.Core.Models.Data; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Services; + +public class EventRouteService( + [FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService, + [FromKeyedServices("storage")] IEventWriteService storageEventWriteService, + IFeatureService _featureService) : IEventWriteService +{ + public async Task CreateAsync(IEvent e) + { + if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) + { + await broadcastEventWriteService.CreateAsync(e); + } + else + { + await storageEventWriteService.CreateAsync(e); + } + } + + public async Task CreateManyAsync(IEnumerable e) + { + if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) + { + await broadcastEventWriteService.CreateManyAsync(e); + } + else + { + await storageEventWriteService.CreateManyAsync(e); + } + } +} diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index bb37e240c8..366b562485 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Bit.Core; using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Context; @@ -94,13 +93,7 @@ public class Startup services.AddKeyedSingleton("broadcast"); } } - services.AddScoped(sp => - { - var featureService = sp.GetRequiredService(); - var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations) - ? "broadcast" : "storage"; - return sp.GetRequiredKeyedService(key); - }); + services.AddScoped(); services.AddScoped(); services.AddOptionality(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 9883e6db47..26e5c7abaf 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; using Azure.Storage.Queues; -using Bit.Core; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; @@ -366,13 +365,7 @@ public static class ServiceCollectionExtensions services.AddKeyedSingleton("storage"); services.AddKeyedSingleton("broadcast"); } - services.AddScoped(sp => - { - var featureService = sp.GetRequiredService(); - var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations) - ? "broadcast" : "storage"; - return sp.GetRequiredKeyedService(key); - }); + services.AddScoped(); if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString)) { diff --git a/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs new file mode 100644 index 0000000000..f593a4628b --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs @@ -0,0 +1,65 @@ +using Bit.Core.Models.Data; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class EventRouteServiceTests +{ + private readonly IEventWriteService _broadcastEventWriteService = Substitute.For(); + private readonly IEventWriteService _storageEventWriteService = Substitute.For(); + private readonly IFeatureService _featureService = Substitute.For(); + private readonly EventRouteService Subject; + + public EventRouteServiceTests() + { + Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService); + } + + [Theory, BitAutoData] + public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); + + await Subject.CreateAsync(eventMessage); + + _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + _storageEventWriteService.Received(1).CreateAsync(eventMessage); + } + + [Theory, BitAutoData] + public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); + + await Subject.CreateAsync(eventMessage); + + _broadcastEventWriteService.Received(1).CreateAsync(eventMessage); + _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable eventMessages) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); + + await Subject.CreateManyAsync(eventMessages); + + _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); + _storageEventWriteService.Received(1).CreateManyAsync(eventMessages); + } + + [Theory, BitAutoData] + public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable eventMessages) + { + _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); + + await Subject.CreateManyAsync(eventMessages); + + _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages); + _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); + } +} From 7fe022e26fce3c3f032757e832df50e9478e6658 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 5 May 2025 09:48:43 -0400 Subject: [PATCH 4/7] Add SmMaxProjects to OrganizationLicense (#5678) * Add SmMaxProjects to OrganizationLicense * Run dotnet format --- .../Queries/Projects/MaxProjectsQuery.cs | 64 ++++++-- .../Queries/Projects/MaxProjectsQueryTests.cs | 150 +++++++++++++++++- src/Core/Billing/Licenses/LicenseConstants.cs | 1 + .../Billing/Licenses/Models/LicenseContext.cs | 1 + .../OrganizationLicenseClaimsFactory.cs | 5 + .../Cloud/CloudGetOrganizationLicenseQuery.cs | 12 +- src/Core/Services/ILicensingService.cs | 3 +- .../Implementations/LicensingService.cs | 3 +- .../NoopLicensingService.cs | 2 +- .../CloudGetOrganizationLicenseQueryTests.cs | 6 +- 10 files changed, 226 insertions(+), 21 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index d9a7d4a2ce..394e8aa9bc 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,9 +1,14 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Utilities; +using Bit.Core.Services; +using Bit.Core.Settings; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -11,13 +16,22 @@ public class MaxProjectsQuery : IMaxProjectsQuery { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IGlobalSettings _globalSettings; + private readonly ILicensingService _licensingService; + private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, - IProjectRepository projectRepository) + IProjectRepository projectRepository, + IGlobalSettings globalSettings, + ILicensingService licensingService, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; + _globalSettings = globalSettings; + _licensingService = licensingService; + _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) @@ -28,19 +42,47 @@ public class MaxProjectsQuery : IMaxProjectsQuery throw new NotFoundException(); } - // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 - var plan = StaticStore.GetPlan(org.PlanType); - if (plan?.SecretsManager == null) + var (planType, maxProjects) = await GetPlanTypeAndMaxProjectsAsync(org); + + if (planType != PlanType.Free) { - throw new BadRequestException("Existing plan not found."); + return (null, null); } - if (plan.Type == PlanType.Free) + var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); + return ((short? max, bool? overMax))(projects + projectsToAdd > maxProjects ? (maxProjects, true) : (maxProjects, false)); + } + + private async Task<(PlanType planType, int maxProjects)> GetPlanTypeAndMaxProjectsAsync(Organization organization) + { + if (_globalSettings.SelfHosted) { - var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); - return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false)); + var license = await _licensingService.ReadOrganizationLicenseAsync(organization); + + if (license == null) + { + throw new BadRequestException("License not found."); + } + + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var maxProjects = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmMaxProjects); + + if (!maxProjects.HasValue) + { + throw new BadRequestException("License does not contain a value for max Secrets Manager projects"); + } + + var planType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType); + return (planType, maxProjects.Value); } - return (null, null); + var plan = await _pricingClient.GetPlan(organization.PlanType); + + if (plan is { SupportsSecretsManager: true }) + { + return (plan.Type, plan.SecretsManager.MaxProjects); + } + + throw new BadRequestException("Existing plan not found."); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 347f5b2128..158463fcfa 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,9 +1,16 @@ -using Bit.Commercial.Core.SecretsManager.Queries.Projects; +using System.Security.Claims; +using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; +using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -32,7 +39,7 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.FamiliesAnnually2019)] [BitAutoData(PlanType.Custom)] [BitAutoData(PlanType.FamiliesAnnually)] - public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType, + public async Task GetByOrgIdAsync_Cloud_SmPlanIsNull_ThrowsBadRequest(PlanType planType, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; @@ -40,6 +47,34 @@ public class MaxProjectsQueryTests .GetByIdAsync(organization.Id) .Returns(organization); + sutProvider.GetDependency().SelfHosted.Returns(false); + var plan = StaticStore.GetPlan(planType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData] + public async Task GetByOrgIdAsync_SelfHosted_NoMaxProjectsClaim_ThrowsBadRequest( + SutProvider sutProvider, Organization organization) + { + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency().SelfHosted.Returns(true); + + var license = new OrganizationLicense(); + var claimsPrincipal = new ClaimsPrincipal(); + sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); + sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + await Assert.ThrowsAsync( async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); @@ -62,12 +97,58 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.EnterpriseAnnually2019)] [BitAutoData(PlanType.EnterpriseAnnually2020)] [BitAutoData(PlanType.EnterpriseAnnually)] - public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType, + public async Task GetByOrgIdAsync_Cloud_SmNoneFreePlans_ReturnsNull(PlanType planType, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().SelfHosted.Returns(false); + var plan = StaticStore.GetPlan(planType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); + + var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); + + Assert.Null(limit); + Assert.Null(overLimit); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task GetByOrgIdAsync_SelfHosted_SmNoneFreePlans_ReturnsNull(PlanType planType, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().SelfHosted.Returns(true); + + var license = new OrganizationLicense(); + var plan = StaticStore.GetPlan(planType); + var claims = new List + { + new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()), + new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString()) + }; + var identity = new ClaimsIdentity(claims, "TestAuthenticationType"); + var claimsPrincipal = new ClaimsPrincipal(identity); + sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); + sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); Assert.Null(limit); @@ -102,7 +183,7 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.Free, 3, 4, true)] [BitAutoData(PlanType.Free, 4, 4, true)] [BitAutoData(PlanType.Free, 40, 4, true)] - public async Task GetByOrgIdAsync_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, + public async Task GetByOrgIdAsync_Cloud_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; @@ -110,6 +191,67 @@ public class MaxProjectsQueryTests sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); + sutProvider.GetDependency().SelfHosted.Returns(false); + var plan = StaticStore.GetPlan(planType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); + + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); + + Assert.NotNull(max); + Assert.NotNull(overMax); + Assert.Equal(3, max.Value); + Assert.Equal(expectedOverMax, overMax); + + await sutProvider.GetDependency().Received(1) + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData(PlanType.Free, 0, 1, false)] + [BitAutoData(PlanType.Free, 1, 1, false)] + [BitAutoData(PlanType.Free, 2, 1, false)] + [BitAutoData(PlanType.Free, 3, 1, true)] + [BitAutoData(PlanType.Free, 4, 1, true)] + [BitAutoData(PlanType.Free, 40, 1, true)] + [BitAutoData(PlanType.Free, 0, 2, false)] + [BitAutoData(PlanType.Free, 1, 2, false)] + [BitAutoData(PlanType.Free, 2, 2, true)] + [BitAutoData(PlanType.Free, 3, 2, true)] + [BitAutoData(PlanType.Free, 4, 2, true)] + [BitAutoData(PlanType.Free, 40, 2, true)] + [BitAutoData(PlanType.Free, 0, 3, false)] + [BitAutoData(PlanType.Free, 1, 3, true)] + [BitAutoData(PlanType.Free, 2, 3, true)] + [BitAutoData(PlanType.Free, 3, 3, true)] + [BitAutoData(PlanType.Free, 4, 3, true)] + [BitAutoData(PlanType.Free, 40, 3, true)] + [BitAutoData(PlanType.Free, 0, 4, true)] + [BitAutoData(PlanType.Free, 1, 4, true)] + [BitAutoData(PlanType.Free, 2, 4, true)] + [BitAutoData(PlanType.Free, 3, 4, true)] + [BitAutoData(PlanType.Free, 4, 4, true)] + [BitAutoData(PlanType.Free, 40, 4, true)] + public async Task GetByOrgIdAsync_SelfHosted_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) + .Returns(projects); + sutProvider.GetDependency().SelfHosted.Returns(true); + + var license = new OrganizationLicense(); + var plan = StaticStore.GetPlan(planType); + var claims = new List + { + new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()), + new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString()) + }; + var identity = new ClaimsIdentity(claims, "TestAuthenticationType"); + var claimsPrincipal = new ClaimsPrincipal(identity); + sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); + sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); Assert.NotNull(max); diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 513578f43e..8ef896d6f9 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -34,6 +34,7 @@ public static class OrganizationLicenseConstants public const string UseSecretsManager = nameof(UseSecretsManager); public const string SmSeats = nameof(SmSeats); public const string SmServiceAccounts = nameof(SmServiceAccounts); + public const string SmMaxProjects = nameof(SmMaxProjects); public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion); public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems); public const string UseRiskInsights = nameof(UseRiskInsights); diff --git a/src/Core/Billing/Licenses/Models/LicenseContext.cs b/src/Core/Billing/Licenses/Models/LicenseContext.cs index 8dcc24e939..01eb3ac80c 100644 --- a/src/Core/Billing/Licenses/Models/LicenseContext.cs +++ b/src/Core/Billing/Licenses/Models/LicenseContext.cs @@ -7,4 +7,5 @@ public class LicenseContext { public Guid? InstallationId { get; init; } public required SubscriptionInfo SubscriptionInfo { get; init; } + public int? SmMaxProjects { get; set; } } diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 6819d3cc0b..7406ac16d9 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -112,6 +112,11 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory GetLicenseAsync(Organization organization, Guid installationId, @@ -42,7 +46,11 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer var subscriptionInfo = await GetSubscriptionAsync(organization); var license = new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version); - license.Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo); + var plan = await _pricingClient.GetPlan(organization.PlanType); + int? smMaxProjects = plan?.SupportsSecretsManager ?? false + ? plan.SecretsManager.MaxProjects + : null; + license.Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo, smMaxProjects); return license; } diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Services/ILicensingService.cs index 2115e43085..9c497ed538 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Services/ILicensingService.cs @@ -21,7 +21,8 @@ public interface ILicensingService Task CreateOrganizationTokenAsync( Organization organization, Guid installationId, - SubscriptionInfo subscriptionInfo); + SubscriptionInfo subscriptionInfo, + int? smMaxProjects); Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); } diff --git a/src/Core/Services/Implementations/LicensingService.cs b/src/Core/Services/Implementations/LicensingService.cs index dd603b4b63..e3509bc964 100644 --- a/src/Core/Services/Implementations/LicensingService.cs +++ b/src/Core/Services/Implementations/LicensingService.cs @@ -339,12 +339,13 @@ public class LicensingService : ILicensingService } } - public async Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + public async Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo, int? smMaxProjects) { var licenseContext = new LicenseContext { InstallationId = installationId, SubscriptionInfo = subscriptionInfo, + SmMaxProjects = smMaxProjects }; var claims = await _organizationLicenseClaimsFactory.GenerateClaims(organization, licenseContext); diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Services/NoopImplementations/NoopLicensingService.cs index b181e61138..de5e954d44 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Services/NoopImplementations/NoopLicensingService.cs @@ -62,7 +62,7 @@ public class NoopLicensingService : ILicensingService return null; } - public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo, int? smMaxProjects) { return Task.FromResult(null); } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs index cc8ab956ca..7af9044c80 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -8,6 +9,7 @@ using Bit.Core.OrganizationFeatures.OrganizationLicenses; using Bit.Core.Platform.Installations; using Bit.Core.Services; using Bit.Core.Test.AutoFixture; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -76,8 +78,10 @@ public class CloudGetOrganizationLicenseQueryTests sutProvider.GetDependency().GetByIdAsync(installationId).Returns(installation); sutProvider.GetDependency().GetSubscriptionAsync(organization).Returns(subInfo); sutProvider.GetDependency().SignLicense(Arg.Any()).Returns(licenseSignature); + var plan = StaticStore.GetPlan(organization.PlanType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); sutProvider.GetDependency() - .CreateOrganizationTokenAsync(organization, installationId, subInfo) + .CreateOrganizationTokenAsync(organization, installationId, subInfo, plan.SecretsManager.MaxProjects) .Returns(token); var result = await sutProvider.Sut.GetLicenseAsync(organization, installationId); From 887fa463741c36c6a8770f55a13e04d676be5491 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 5 May 2025 16:17:31 +0100 Subject: [PATCH 5/7] Resolve the send email bug (#5763) --- .../OrganizationSponsorshipsController.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 9a328081fe..b007c05730 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -116,7 +116,7 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task ResendSponsorshipOffer(Guid sponsoringOrgId) + public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) { var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); @@ -129,11 +129,14 @@ public class OrganizationSponsorshipsController : Controller var sponsoringOrgUser = await _organizationUserRepository .GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); - await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync( - await _organizationRepository.GetByIdAsync(sponsoringOrgId), - sponsoringOrgUser, - await _organizationSponsorshipRepository - .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id)); + var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); + var filteredSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase)); + if (filteredSponsorship != null) + { + await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync( + await _organizationRepository.GetByIdAsync(sponsoringOrgId), + sponsoringOrgUser, filteredSponsorship); + } } [Authorize("Application")] From e2f0ddf373b0669b987eeadac3c9fc7480ce4cbe Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 5 May 2025 13:36:43 -0400 Subject: [PATCH 6/7] [PM-19383] add admin endpoint, fix typecasting error (#5681) * add admin endpoint, fix typecasting error * fix typecast issue * wip * cleanup --- .../Vault/Controllers/CiphersController.cs | 21 +++++++++++++++---- .../Services/Implementations/CipherService.cs | 8 +++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 3bdb6c4bf0..03b83e3de2 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1241,6 +1241,20 @@ public class CiphersController : Controller return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); } + [HttpGet("{id}/attachment/{attachmentId}/admin")] + public async Task GetAttachmentDataAdmin(Guid id, string attachmentId) + { + var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(id); + if (cipher == null || !cipher.OrganizationId.HasValue || + !await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) + { + throw new NotFoundException(); + } + + var result = await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); + return new AttachmentResponseModel(result); + } + [HttpGet("{id}/attachment/{attachmentId}")] public async Task GetAttachmentData(Guid id, string attachmentId) { @@ -1287,18 +1301,17 @@ public class CiphersController : Controller [HttpDelete("{id}/attachment/{attachmentId}/admin")] [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] - public async Task DeleteAttachmentAdmin(string id, string attachmentId) + public async Task DeleteAttachmentAdmin(Guid id, string attachmentId) { - var idGuid = new Guid(id); var userId = _userService.GetProperUserId(User).Value; - var cipher = await _cipherRepository.GetByIdAsync(idGuid); + var cipher = await _cipherRepository.GetByIdAsync(id); if (cipher == null || !cipher.OrganizationId.HasValue || !await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) { throw new NotFoundException(); } - await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); + return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); } [AllowAnonymous] diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 745d90b741..73212ab72e 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -379,7 +379,7 @@ public class CipherService : ICipherService if (!valid || realSize > MAX_FILE_SIZE) { // File reported differs in size from that promised. Must be a rogue client. Delete Send - await DeleteAttachmentAsync(cipher, attachmentData); + await DeleteAttachmentAsync(cipher, attachmentData, false); return false; } // Update Send data if necessary @@ -483,7 +483,7 @@ public class CipherService : ICipherService throw new NotFoundException(); } - return await DeleteAttachmentAsync(cipher, cipher.GetAttachments()[attachmentId]); + return await DeleteAttachmentAsync(cipher, cipher.GetAttachments()[attachmentId], orgAdmin); } public async Task PurgeAsync(Guid organizationId) @@ -877,7 +877,7 @@ public class CipherService : ICipherService } } - private async Task DeleteAttachmentAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + private async Task DeleteAttachmentAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, bool orgAdmin) { if (attachmentData == null || string.IsNullOrWhiteSpace(attachmentData.AttachmentId)) { @@ -891,7 +891,7 @@ public class CipherService : ICipherService // Update the revision date when an attachment is deleted cipher.RevisionDate = DateTime.UtcNow; - await _cipherRepository.ReplaceAsync((CipherDetails)cipher); + await _cipherRepository.ReplaceAsync(orgAdmin ? cipher : (CipherDetails)cipher); // push await _pushService.PushSyncCipherUpdateAsync(cipher, null); From 10fcff58b223a6b78c85bc9974e732f9de7694ac Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Tue, 6 May 2025 03:48:40 -0500 Subject: [PATCH 7/7] PM-19715 & PM-19712 Move Files to DIRT ownership on Server (#5769) * PM-19715 PM-19711 moving reports to dirt directory and adding dirt as codeowners * PM-19715 creating two sub folders for reports and events * PM-19714 changing dirt paths for codeowners * PM-19714 fixing codeowners file * PM-19714 fixing codeowners * PM-19714 moving hibpController to dirt ownership * PM-19715 moving controller --- .github/CODEOWNERS | 8 ++++++++ src/Api/{Tools => Dirt}/Controllers/HibpController.cs | 0 src/Api/{Tools => Dirt}/Controllers/ReportsController.cs | 0 .../Models/PasswordHealthReportApplicationModel.cs | 0 .../Models/Response/MemberAccessReportModel.cs | 0 .../Models/Response/MemberCipherDetailsResponseModel.cs | 0 .../Reports}/Entities/PasswordHealthReportApplication.cs | 0 .../Reports}/Models/Data/MemberAccessCipherDetails.cs | 0 .../AddPasswordHealthReportApplicationCommand.cs | 0 .../DropPasswordHealthReportApplicationCommand.cs | 0 .../GetPasswordHealthReportApplicationQuery.cs | 0 .../IAddPasswordHealthReportApplicationCommand.cs | 0 .../IDropPasswordHealthReportApplicationCommand.cs | 0 .../IGetPasswordHealthReportApplicationQuery.cs | 0 .../ReportFeatures/MemberAccessCipherDetailsQuery.cs | 0 .../Interfaces/IMemberAccessCipherDetailsQuery.cs | 0 .../ReportingServiceCollectionExtensions.cs | 0 .../Requests/AddPasswordHealthReportApplicationRequest.cs | 0 .../DropPasswordHealthReportApplicationRequest.cs | 0 .../Requests/MemberAccessCipherDetailsRequest.cs | 0 .../IPasswordHealthReportApplicationRepository.cs | 0 .../PasswordHealthReportApplicationRepository.cs | 0 .../{Tools/Controllers => Dirt}/ReportsControllerTests.cs | 0 .../AddPasswordHealthReportApplicationCommandTests.cs | 0 .../DeletePasswordHealthReportApplicationCommandTests.cs | 0 .../GetPasswordHealthReportApplicationQueryTests.cs | 0 26 files changed, 8 insertions(+) rename src/Api/{Tools => Dirt}/Controllers/HibpController.cs (100%) rename src/Api/{Tools => Dirt}/Controllers/ReportsController.cs (100%) rename src/Api/{Tools => Dirt}/Models/PasswordHealthReportApplicationModel.cs (100%) rename src/Api/{Tools => Dirt}/Models/Response/MemberAccessReportModel.cs (100%) rename src/Api/{Tools => Dirt}/Models/Response/MemberCipherDetailsResponseModel.cs (100%) rename src/Core/{Tools => Dirt/Reports}/Entities/PasswordHealthReportApplication.cs (100%) rename src/Core/{Tools => Dirt/Reports}/Models/Data/MemberAccessCipherDetails.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/MemberAccessCipherDetailsQuery.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/ReportingServiceCollectionExtensions.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs (100%) rename src/Core/{Tools => Dirt/Reports}/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs (100%) rename src/Core/{Tools => Dirt/Reports}/Repositories/IPasswordHealthReportApplicationRepository.cs (100%) rename src/Infrastructure.Dapper/{Tools/Repositories => Dirt}/PasswordHealthReportApplicationRepository.cs (100%) rename test/Api.Test/{Tools/Controllers => Dirt}/ReportsControllerTests.cs (100%) rename test/Core.Test/{Tools => Dirt}/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs (100%) rename test/Core.Test/{Tools => Dirt}/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs (100%) rename test/Core.Test/{Tools => Dirt}/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 973405dea5..9f3048a340 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,8 +43,16 @@ src/Core/IdentityServer @bitwarden/team-auth-dev # Key Management team **/KeyManagement @bitwarden/team-key-management-dev +# Tools team **/Tools @bitwarden/team-tools-dev +# Dirt (Data Insights & Reporting) team +src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev +src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev +src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev +test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev +test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev + # Vault team **/Vault @bitwarden/team-vault-dev **/Vault/AuthorizationHandlers @bitwarden/team-vault-dev @bitwarden/team-admin-console-dev # joint ownership over authorization handlers that affect organization users diff --git a/src/Api/Tools/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs similarity index 100% rename from src/Api/Tools/Controllers/HibpController.cs rename to src/Api/Dirt/Controllers/HibpController.cs diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs similarity index 100% rename from src/Api/Tools/Controllers/ReportsController.cs rename to src/Api/Dirt/Controllers/ReportsController.cs diff --git a/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs similarity index 100% rename from src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs rename to src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs diff --git a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs b/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs similarity index 100% rename from src/Api/Tools/Models/Response/MemberAccessReportModel.cs rename to src/Api/Dirt/Models/Response/MemberAccessReportModel.cs diff --git a/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs similarity index 100% rename from src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs rename to src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs diff --git a/src/Core/Tools/Entities/PasswordHealthReportApplication.cs b/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs similarity index 100% rename from src/Core/Tools/Entities/PasswordHealthReportApplication.cs rename to src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs diff --git a/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs similarity index 100% rename from src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs rename to src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs diff --git a/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs diff --git a/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs rename to src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs diff --git a/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs rename to src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs diff --git a/src/Core/Tools/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs rename to src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs diff --git a/src/Core/Tools/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs rename to src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs diff --git a/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs similarity index 100% rename from src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs rename to src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs diff --git a/src/Core/Tools/Repositories/IPasswordHealthReportApplicationRepository.cs b/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs similarity index 100% rename from src/Core/Tools/Repositories/IPasswordHealthReportApplicationRepository.cs rename to src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs diff --git a/src/Infrastructure.Dapper/Tools/Repositories/PasswordHealthReportApplicationRepository.cs b/src/Infrastructure.Dapper/Dirt/PasswordHealthReportApplicationRepository.cs similarity index 100% rename from src/Infrastructure.Dapper/Tools/Repositories/PasswordHealthReportApplicationRepository.cs rename to src/Infrastructure.Dapper/Dirt/PasswordHealthReportApplicationRepository.cs diff --git a/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs b/test/Api.Test/Dirt/ReportsControllerTests.cs similarity index 100% rename from test/Api.Test/Tools/Controllers/ReportsControllerTests.cs rename to test/Api.Test/Dirt/ReportsControllerTests.cs diff --git a/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs similarity index 100% rename from test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs rename to test/Core.Test/Dirt/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs diff --git a/test/Core.Test/Tools/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs similarity index 100% rename from test/Core.Test/Tools/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs rename to test/Core.Test/Dirt/ReportFeatures/DeletePasswordHealthReportApplicationCommandTests.cs diff --git a/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs similarity index 100% rename from test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs rename to test/Core.Test/Dirt/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs