From a7fc89a5bb5a85855251fdd34ca2886621099c4f Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 25 Aug 2025 14:34:06 -0500 Subject: [PATCH 01/85] Removing extra semi colon (#6246) --- .../AdminConsole/Repositories/TableStorage/EventRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs index cf661ae346..c9c803b5b2 100644 --- a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs +++ b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs @@ -39,7 +39,7 @@ public class EventRepository : IEventRepository DateTime startDate, DateTime endDate, PageOptions pageOptions) { return await GetManyAsync($"OrganizationId={secret.OrganizationId}", - $"SecretId={secret.Id}__Date={{0}}", startDate, endDate, pageOptions); ; + $"SecretId={secret.Id}__Date={{0}}", startDate, endDate, pageOptions); } public async Task> GetManyByProjectAsync(Project project, From a4c4d0157bff55cc665518cda6afec53943e89c5 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:00:41 -0700 Subject: [PATCH 02/85] check for UserId in ReplaceAsync (#6176) --- .../Vault/Repositories/CipherRepository.cs | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 11a74a8097..3fae537a1e 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -553,59 +553,61 @@ public class CipherRepository : Repository(cipher.UserId.Value.ToString(), true), }); - cipher.Favorites = JsonSerializer.Serialize(jsonObject); + cipher.Favorites = JsonSerializer.Serialize(jsonObject); + } + else + { + var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); + favorites.Add(cipher.UserId.Value, true); + cipher.Favorites = JsonSerializer.Serialize(favorites); + } } else { - var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); - favorites.Add(cipher.UserId.Value, true); - cipher.Favorites = JsonSerializer.Serialize(favorites); - } - } - else - { - if (cipher.Favorites != null && cipher.Favorites.Contains(cipher.UserId.Value.ToString())) - { - var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); - favorites.Remove(cipher.UserId.Value); - cipher.Favorites = JsonSerializer.Serialize(favorites); - } - } - if (cipher.FolderId.HasValue) - { - if (cipher.Folders == null) - { - var jsonObject = new JsonObject(new[] + if (cipher.Favorites != null && cipher.Favorites.Contains(cipher.UserId.Value.ToString())) { + var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); + favorites.Remove(cipher.UserId.Value); + cipher.Favorites = JsonSerializer.Serialize(favorites); + } + } + if (cipher.FolderId.HasValue) + { + if (cipher.Folders == null) + { + var jsonObject = new JsonObject(new[] + { new KeyValuePair(cipher.UserId.Value.ToString(), cipher.FolderId), }); - cipher.Folders = JsonSerializer.Serialize(jsonObject); + cipher.Folders = JsonSerializer.Serialize(jsonObject); + } + else + { + var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); + folders.Add(cipher.UserId.Value, cipher.FolderId.Value); + cipher.Folders = JsonSerializer.Serialize(folders); + } } else { - var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); - folders.Add(cipher.UserId.Value, cipher.FolderId.Value); - cipher.Folders = JsonSerializer.Serialize(folders); + if (cipher.Folders != null && cipher.Folders.Contains(cipher.UserId.Value.ToString())) + { + var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); + folders.Remove(cipher.UserId.Value); + cipher.Folders = JsonSerializer.Serialize(folders); + } } } - else - { - if (cipher.Folders != null && cipher.Folders.Contains(cipher.UserId.Value.ToString())) - { - var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); - folders.Remove(cipher.UserId.Value); - cipher.Folders = JsonSerializer.Serialize(folders); - } - } - // Check if this cipher is a part of an organization, and if so do // not save the UserId into the database. This must be done after we // set the user specific data like Folders and Favorites because From 004e6285a16b395eb713c6b8b46a7ccfbbf1f78c Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 26 Aug 2025 07:35:23 -0500 Subject: [PATCH 03/85] PM-21024 ChangePasswordUri controller + service (#5845) * add ChangePasswordUri controller and service to Icons * add individual settings for change password uri * add logging to change password uri controller * use custom http client that follows redirects * add ChangePasswordUriService tests * remove unneeded null check * fix copy pasta - changePasswordUriSettings * add `HelpUsersUpdatePasswords` policy * Remove policy for change password uri - this was removed from scope * fix nullable warnings --- .../ChangePasswordUriController.cs | 89 +++++++++++++++ src/Icons/Models/ChangePasswordUriResponse.cs | 11 ++ src/Icons/Models/ChangePasswordUriSettings.cs | 8 ++ .../Services/ChangePasswordUriService.cs | 89 +++++++++++++++ .../Services/IChangePasswordUriService.cs | 6 + src/Icons/Startup.cs | 8 ++ src/Icons/Util/ServiceCollectionExtension.cs | 19 +++ src/Icons/appsettings.json | 5 + .../Services/ChangePasswordUriServiceTests.cs | 108 ++++++++++++++++++ 9 files changed, 343 insertions(+) create mode 100644 src/Icons/Controllers/ChangePasswordUriController.cs create mode 100644 src/Icons/Models/ChangePasswordUriResponse.cs create mode 100644 src/Icons/Models/ChangePasswordUriSettings.cs create mode 100644 src/Icons/Services/ChangePasswordUriService.cs create mode 100644 src/Icons/Services/IChangePasswordUriService.cs create mode 100644 test/Icons.Test/Services/ChangePasswordUriServiceTests.cs diff --git a/src/Icons/Controllers/ChangePasswordUriController.cs b/src/Icons/Controllers/ChangePasswordUriController.cs new file mode 100644 index 0000000000..3f2bc91cf2 --- /dev/null +++ b/src/Icons/Controllers/ChangePasswordUriController.cs @@ -0,0 +1,89 @@ +using Bit.Icons.Models; +using Bit.Icons.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Bit.Icons.Controllers; + +[Route("change-password-uri")] +public class ChangePasswordUriController : Controller +{ + private readonly IMemoryCache _memoryCache; + private readonly IDomainMappingService _domainMappingService; + private readonly IChangePasswordUriService _changePasswordService; + private readonly ChangePasswordUriSettings _changePasswordSettings; + private readonly ILogger _logger; + + public ChangePasswordUriController( + IMemoryCache memoryCache, + IDomainMappingService domainMappingService, + IChangePasswordUriService changePasswordService, + ChangePasswordUriSettings changePasswordUriSettings, + ILogger logger) + { + _memoryCache = memoryCache; + _domainMappingService = domainMappingService; + _changePasswordService = changePasswordService; + _changePasswordSettings = changePasswordUriSettings; + _logger = logger; + } + + [HttpGet("config")] + public IActionResult GetConfig() + { + return new JsonResult(new + { + _changePasswordSettings.CacheEnabled, + _changePasswordSettings.CacheHours, + _changePasswordSettings.CacheSizeLimit + }); + } + + [HttpGet] + public async Task Get([FromQuery] string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return new BadRequestResult(); + } + + var uriHasProtocol = uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + var url = uriHasProtocol ? uri : $"https://{uri}"; + if (!Uri.TryCreate(url, UriKind.Absolute, out var validUri)) + { + return new BadRequestResult(); + } + + var domain = validUri.Host; + + var mappedDomain = _domainMappingService.MapDomain(domain); + if (!_changePasswordSettings.CacheEnabled || !_memoryCache.TryGetValue(mappedDomain, out string? changePasswordUri)) + { + var result = await _changePasswordService.GetChangePasswordUri(domain); + if (result == null) + { + _logger.LogWarning("Null result returned for {0}.", domain); + changePasswordUri = null; + } + else + { + changePasswordUri = result; + } + + if (_changePasswordSettings.CacheEnabled) + { + _logger.LogInformation("Cache uri for {0}.", domain); + _memoryCache.Set(mappedDomain, changePasswordUri, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = new TimeSpan(_changePasswordSettings.CacheHours, 0, 0), + Size = changePasswordUri?.Length ?? 0, + Priority = changePasswordUri == null ? CacheItemPriority.High : CacheItemPriority.Normal + }); + } + } + + return Ok(new ChangePasswordUriResponse(changePasswordUri)); + } +} diff --git a/src/Icons/Models/ChangePasswordUriResponse.cs b/src/Icons/Models/ChangePasswordUriResponse.cs new file mode 100644 index 0000000000..def6806bd3 --- /dev/null +++ b/src/Icons/Models/ChangePasswordUriResponse.cs @@ -0,0 +1,11 @@ +namespace Bit.Icons.Models; + +public class ChangePasswordUriResponse +{ + public string? uri { get; set; } + + public ChangePasswordUriResponse(string? uri) + { + this.uri = uri; + } +} diff --git a/src/Icons/Models/ChangePasswordUriSettings.cs b/src/Icons/Models/ChangePasswordUriSettings.cs new file mode 100644 index 0000000000..bcb804f4e0 --- /dev/null +++ b/src/Icons/Models/ChangePasswordUriSettings.cs @@ -0,0 +1,8 @@ +namespace Bit.Icons.Models; + +public class ChangePasswordUriSettings +{ + public virtual bool CacheEnabled { get; set; } + public virtual int CacheHours { get; set; } + public virtual long? CacheSizeLimit { get; set; } +} diff --git a/src/Icons/Services/ChangePasswordUriService.cs b/src/Icons/Services/ChangePasswordUriService.cs new file mode 100644 index 0000000000..6f2b73efff --- /dev/null +++ b/src/Icons/Services/ChangePasswordUriService.cs @@ -0,0 +1,89 @@ +namespace Bit.Icons.Services; + +public class ChangePasswordUriService : IChangePasswordUriService +{ + private readonly HttpClient _httpClient; + + public ChangePasswordUriService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient("ChangePasswordUri"); + } + + /// + /// Fetches the well-known change password URL for the given domain. + /// + /// + /// + public async Task GetChangePasswordUri(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) + { + return null; + } + + var hasReliableStatusCode = await HasReliableHttpStatusCode(domain); + var wellKnownChangePasswordUrl = await GetWellKnownChangePasswordUrl(domain); + + + if (hasReliableStatusCode && wellKnownChangePasswordUrl != null) + { + return wellKnownChangePasswordUrl; + } + + // Reliable well-known URL criteria not met, return null + return null; + } + + /// + /// Checks if the server returns a non-200 status code for a resource that should not exist. + // See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics + /// + /// The domain of the URL to check + /// True when the domain responds with a non-ok response + private async Task HasReliableHttpStatusCode(string urlDomain) + { + try + { + var url = new UriBuilder(urlDomain) + { + Path = "/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200" + }; + + var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); + + var response = await _httpClient.SendAsync(request); + return !response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + /// + /// Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response + /// is returned. Returns null if the request throws or the response is not 200 OK. + /// See https://w3c.github.io/webappsec-change-password-url/ + /// + /// The domain of the URL to check + /// The well-known change password URL if valid, otherwise null + private async Task GetWellKnownChangePasswordUrl(string urlDomain) + { + try + { + var url = new UriBuilder(urlDomain) + { + Path = "/.well-known/change-password" + }; + + var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); + + var response = await _httpClient.SendAsync(request); + return response.IsSuccessStatusCode ? url.ToString() : null; + } + catch + { + return null; + } + } +} diff --git a/src/Icons/Services/IChangePasswordUriService.cs b/src/Icons/Services/IChangePasswordUriService.cs new file mode 100644 index 0000000000..f010255db5 --- /dev/null +++ b/src/Icons/Services/IChangePasswordUriService.cs @@ -0,0 +1,6 @@ +namespace Bit.Icons.Services; + +public interface IChangePasswordUriService +{ + Task GetChangePasswordUri(string domain); +} diff --git a/src/Icons/Startup.cs b/src/Icons/Startup.cs index 4695c320e9..16bbdef553 100644 --- a/src/Icons/Startup.cs +++ b/src/Icons/Startup.cs @@ -2,6 +2,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Icons.Extensions; +using Bit.Icons.Models; using Bit.SharedWeb.Utilities; using Microsoft.Net.Http.Headers; @@ -27,8 +28,11 @@ public class Startup // Settings var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); var iconsSettings = new IconsSettings(); + var changePasswordUriSettings = new ChangePasswordUriSettings(); ConfigurationBinder.Bind(Configuration.GetSection("IconsSettings"), iconsSettings); + ConfigurationBinder.Bind(Configuration.GetSection("ChangePasswordUriSettings"), changePasswordUriSettings); services.AddSingleton(s => iconsSettings); + services.AddSingleton(s => changePasswordUriSettings); // Http client services.ConfigureHttpClients(); @@ -41,6 +45,10 @@ public class Startup { options.SizeLimit = iconsSettings.CacheSizeLimit; }); + services.AddMemoryCache(options => + { + options.SizeLimit = changePasswordUriSettings.CacheSizeLimit; + }); // Services services.AddServices(); diff --git a/src/Icons/Util/ServiceCollectionExtension.cs b/src/Icons/Util/ServiceCollectionExtension.cs index 5492cda0cf..3bd3537198 100644 --- a/src/Icons/Util/ServiceCollectionExtension.cs +++ b/src/Icons/Util/ServiceCollectionExtension.cs @@ -28,6 +28,24 @@ public static class ServiceCollectionExtension AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, }); + + // The CreatePasswordUri handler wants similar headers as Icons to portray coming from a browser but + // needs to follow redirects to get the final URL. + services.AddHttpClient("ChangePasswordUri", client => + { + client.Timeout = TimeSpan.FromSeconds(20); + client.MaxResponseContentBufferSize = 5000000; // 5 MB + // Let's add some headers to look like we're coming from a web browser request. Some websites + // will block our request without these. + client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.8"); + client.DefaultRequestHeaders.Add("Cache-Control", "no-cache"); + client.DefaultRequestHeaders.Add("Pragma", "no-cache"); + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }); } public static void AddHtmlParsing(this IServiceCollection services) @@ -40,5 +58,6 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Icons/appsettings.json b/src/Icons/appsettings.json index 6b4e2992e0..5e1113b150 100644 --- a/src/Icons/appsettings.json +++ b/src/Icons/appsettings.json @@ -6,5 +6,10 @@ "cacheEnabled": true, "cacheHours": 24, "cacheSizeLimit": null + }, + "changePasswordUriSettings": { + "cacheEnabled": true, + "cacheHours": 24, + "cacheSizeLimit": null } } diff --git a/test/Icons.Test/Services/ChangePasswordUriServiceTests.cs b/test/Icons.Test/Services/ChangePasswordUriServiceTests.cs new file mode 100644 index 0000000000..53b883733b --- /dev/null +++ b/test/Icons.Test/Services/ChangePasswordUriServiceTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using Bit.Icons.Services; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; + +namespace Bit.Icons.Test.Services; + +public class ChangePasswordUriServiceTests : ServiceTestBase +{ + [Theory] + [InlineData("https://example.com", "https://example.com:443/.well-known/change-password")] + public async Task GetChangePasswordUri_WhenBothChecksPass_ReturnsWellKnownUrl(string domain, string expectedUrl) + { + // Arrange + var mockedHandler = new MockedHttpMessageHandler(); + + var nonExistentUrl = $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200"; + var changePasswordUrl = $"{domain}/.well-known/change-password"; + + // Mock the response for the resource-that-should-not-exist request (returns 404) + mockedHandler + .When(nonExistentUrl) + .RespondWith(HttpStatusCode.NotFound) + .WithContent(new StringContent("Not found")); + + // Mock the response for the change-password request (returns 200) + mockedHandler + .When(changePasswordUrl) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Ok")); + + var mockHttpFactory = Substitute.For(); + mockHttpFactory.CreateClient("ChangePasswordUri").Returns(mockedHandler.ToHttpClient()); + + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Equal(expectedUrl, result); + } + + [Theory] + [InlineData("https://example.com")] + public async Task GetChangePasswordUri_WhenResourceThatShouldNotExistReturns200_ReturnsNull(string domain) + { + var mockHttpFactory = Substitute.For(); + var mockedHandler = new MockedHttpMessageHandler(); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Ok")); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/change-password") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Ok")); + + var httpClient = mockedHandler.ToHttpClient(); + mockHttpFactory.CreateClient("ChangePasswordUri").Returns(httpClient); + + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Null(result); + } + + [Theory] + [InlineData("https://example.com")] + public async Task GetChangePasswordUri_WhenChangePasswordUrlNotFound_ReturnsNull(string domain) + { + var mockHttpFactory = Substitute.For(); + var mockedHandler = new MockedHttpMessageHandler(); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200") + .RespondWith(HttpStatusCode.NotFound) + .WithContent(new StringContent("Not found")); + + mockedHandler + .When(HttpMethod.Get, $"{domain}/.well-known/change-password") + .RespondWith(HttpStatusCode.NotFound) + .WithContent(new StringContent("Not found")); + + var httpClient = mockedHandler.ToHttpClient(); + mockHttpFactory.CreateClient("ChangePasswordUri").Returns(httpClient); + + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Null(result); + } + + [Theory] + [InlineData("")] + public async Task GetChangePasswordUri_WhenDomainIsNullOrEmpty_ReturnsNull(string domain) + { + var mockHttpFactory = Substitute.For(); + var service = new ChangePasswordUriService(mockHttpFactory); + + var result = await service.GetChangePasswordUri(domain); + + Assert.Null(result); + } +} From b63e27249087c1c31fd564a20e2c8a88acde4f69 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:28:03 -0500 Subject: [PATCH 04/85] [PM-24551] remove feature flag code for pm-199566-update-msp-to-charge-automatically (#6188) * [PM-24551] remove feature flag code * undoing constructor refactors * reverting changes the refactor made --- .../Controllers/ProvidersController.cs | 40 +++++-------- .../AdminConsole/Views/Providers/Edit.cshtml | 15 +++-- .../PaymentMethodAttachedHandler.cs | 59 +------------------ src/Core/Constants.cs | 1 - 4 files changed, 23 insertions(+), 92 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index c0c138d0bc..9344179a77 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -7,7 +7,6 @@ using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Services; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -39,28 +38,26 @@ namespace Bit.Admin.AdminConsole.Controllers; [SelfHosted(NotSelfHostedOnly = true)] public class ProvidersController : Controller { + private readonly string _stripeUrl; + private readonly string _braintreeMerchantUrl; + private readonly string _braintreeMerchantId; private readonly IOrganizationRepository _organizationRepository; private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand; private readonly IProviderRepository _providerRepository; private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IProviderService _providerService; private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; - private readonly IProviderService _providerService; private readonly ICreateProviderCommand _createProviderCommand; - private readonly IFeatureService _featureService; private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; private readonly IStripeAdapter _stripeAdapter; private readonly IAccessControlService _accessControlService; private readonly ISubscriberService _subscriberService; - private readonly string _stripeUrl; - private readonly string _braintreeMerchantUrl; - private readonly string _braintreeMerchantId; - public ProvidersController( - IOrganizationRepository organizationRepository, + public ProvidersController(IOrganizationRepository organizationRepository, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, @@ -69,7 +66,6 @@ public class ProvidersController : Controller GlobalSettings globalSettings, IApplicationCacheService applicationCacheService, ICreateProviderCommand createProviderCommand, - IFeatureService featureService, IProviderPlanRepository providerPlanRepository, IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, @@ -87,15 +83,14 @@ public class ProvidersController : Controller _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; _createProviderCommand = createProviderCommand; - _featureService = featureService; _providerPlanRepository = providerPlanRepository; _providerBillingService = providerBillingService; _pricingClient = pricingClient; _stripeAdapter = stripeAdapter; + _accessControlService = accessControlService; _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; - _accessControlService = accessControlService; _subscriberService = subscriberService; } @@ -344,21 +339,17 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); - if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically)) + var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) { - var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); - - if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) + var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; + await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { - var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; - await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + Metadata = new Dictionary { - Metadata = new Dictionary - { - [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice - } - }); - } + [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice + } + }); } break; case ProviderType.BusinessUnit: @@ -403,8 +394,7 @@ public class ProvidersController : Controller } var providerPlans = await _providerPlanRepository.GetByProviderId(id); - var payByInvoice = _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && - ((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false); + var payByInvoice = ((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false); return new ProviderEditModel( provider, users, providerOrganizations, diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index ca4fa70ab5..e450322e97 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -1,12 +1,11 @@ -@using Bit.Admin.Enums; -@using Bit.Core +@inject IAccessControlService AccessControlService + +@using Bit.Admin.Enums +@using Bit.Admin.Services @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.Core.Billing.Extensions -@using Microsoft.AspNetCore.Mvc.TagHelpers -@inject Bit.Admin.Services.IAccessControlService AccessControlService -@inject Bit.Core.Services.IFeatureService FeatureService - +@using Bit.Core.Enums @model ProviderEditModel @{ ViewData["Title"] = "Provider: " + Model.Provider.DisplayName(); @@ -114,7 +113,7 @@
-
@@ -144,7 +143,7 @@
- @if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) + @if (Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) {
diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index ee5a50cc98..548a41879c 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -2,12 +2,10 @@ #nullable disable using Bit.Billing.Constants; -using Bit.Core; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; -using Bit.Core.Services; using Stripe; using Event = Stripe.Event; @@ -19,41 +17,22 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IStripeEventUtilityService _stripeEventUtilityService; - private readonly IFeatureService _featureService; private readonly IProviderRepository _providerRepository; - public PaymentMethodAttachedHandler( - ILogger logger, + public PaymentMethodAttachedHandler(ILogger logger, IStripeEventService stripeEventService, IStripeFacade stripeFacade, IStripeEventUtilityService stripeEventUtilityService, - IFeatureService featureService, IProviderRepository providerRepository) { _logger = logger; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _stripeEventUtilityService = stripeEventUtilityService; - _featureService = featureService; _providerRepository = providerRepository; } public async Task HandleAsync(Event parsedEvent) - { - var updateMSPToChargeAutomatically = - _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically); - - if (updateMSPToChargeAutomatically) - { - await HandleVNextAsync(parsedEvent); - } - else - { - await HandleVCurrentAsync(parsedEvent); - } - } - - private async Task HandleVNextAsync(Event parsedEvent) { var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]); @@ -136,42 +115,6 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler } } - private async Task HandleVCurrentAsync(Event parsedEvent) - { - var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); - if (paymentMethod is null) - { - _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); - return; - } - - var subscriptionListOptions = new SubscriptionListOptions - { - Customer = paymentMethod.CustomerId, - Status = StripeSubscriptionStatus.Unpaid, - Expand = ["data.latest_invoice"] - }; - - StripeList unpaidSubscriptions; - try - { - unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions); - } - catch (Exception e) - { - _logger.LogError(e, - "Attempted to get unpaid invoices for customer {CustomerId} but encountered an error while calling Stripe", - paymentMethod.CustomerId); - - return; - } - - foreach (var unpaidSubscription in unpaidSubscriptions) - { - await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); - } - } - private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription) { var latestInvoice = unpaidSubscription.LatestInvoice; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7f55a0710d..2fbf7caffd 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -152,7 +152,6 @@ public static class FeatureFlagKeys public const string UsePricingService = "use-pricing-service"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; From 7a63ae6315f46f08e9a34618281bcfd27cbab6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:38:01 +0100 Subject: [PATCH 05/85] =?UTF-8?q?[PM-22838]=C2=A0Add=20hyperlink=20to=20pr?= =?UTF-8?q?ovider=20name=20in=20Admin=20Panel=20organization=20details=20(?= =?UTF-8?q?#6243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Organizations/_ProviderInformation.cshtml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml index 03ecad452d..f6e068e0ae 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml @@ -2,7 +2,9 @@ @model Bit.Core.AdminConsole.Entities.Provider.Provider
Provider Name
-
@Model.DisplayName()
+
+ @Model.DisplayName() +
Provider Type
@(Model.Type.GetDisplayAttribute()?.GetName())
From e5159a3ba2c40173104198fd621cdc23386854cf Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:30:37 -0400 Subject: [PATCH 06/85] [PM-19659] Clean up Notifications code (#6244) * Move PushType to Platform Folder - Move the PushType next to the rest of push notification code - Specifically exclude it from needing Platform code review - Add tests establishing rules Platform has for usage of this enum, making it safe to have no owner * Move NotificationHub code into Platform/Push directory * Update NotificationHub namespace imports * Add attribute for storing push type metadata * Rename Push Engines to have PushEngine suffix * Move Push Registration items to their own directory * Push code move * Add expected usage comment * Add Push feature registration method - Make method able to be called multipes times with no ill effects * Add Push Registration service entrypoint and tests * Use new service entrypoints * Test changes --- .github/CODEOWNERS | 2 + src/Api/Models/Request/DeviceRequestModels.cs | 2 +- .../Push/Controllers/PushController.cs | 2 +- src/Core/Enums/PushType.cs | 35 ---- src/Core/Models/PushNotification.cs | 6 +- .../AzureQueuePushEngine.cs} | 10 +- .../MultiServicePushNotificationService.cs | 8 +- .../NoopPushEngine.cs} | 5 +- .../NotificationsApiPushEngine.cs} | 12 +- .../RelayPushEngine.cs} | 9 +- .../Push/{Services => }/IPushEngine.cs | 5 +- .../IPushNotificationService.cs | 52 ++++- .../Push/{Services => }/IPushRelayer.cs | 4 +- .../INotificationHubClientProxy.cs | 4 +- .../NotificationHub/INotificationHubPool.cs | 4 +- .../NotificationHubClientProxy.cs | 4 +- .../NotificationHubConnection.cs | 4 +- .../NotificationHub/NotificationHubPool.cs | 4 +- .../NotificationHubPushEngine.cs} | 15 +- .../Push/NotificationInfoAttribute.cs | 44 ++++ .../Push/{Services => }/PushNotification.cs | 3 + .../Push/PushServiceCollectionExtensions.cs | 82 ++++++++ src/Core/Platform/Push/PushType.cs | 93 ++++++++ .../IPushRegistrationService.cs | 8 +- .../NoopPushRegistrationService.cs | 8 +- .../NotificationHubPushRegistrationService.cs | 5 +- .../PushRegistration}/PushRegistrationData.cs | 4 +- ...RegistrationServiceCollectionExtensions.cs | 54 +++++ .../RelayPushRegistrationService.cs | 8 +- src/Core/Services/IDeviceService.cs | 2 +- .../Services/Implementations/DeviceService.cs | 2 +- .../Utilities/ServiceCollectionExtensions.cs | 46 +--- .../Controllers/PushControllerTests.cs | 2 +- .../Push/Controllers/PushControllerTests.cs | 2 +- .../AzureQueuePushEngineTests.cs} | 11 +- .../NotificationsApiPushEngineTests.cs} | 12 +- .../{Services => Engines}/PushTestBase.cs | 3 + .../RelayPushEngineTests.cs} | 7 +- ...ultiServicePushNotificationServiceTests.cs | 57 +++++ .../NotificationHubConnectionTests.cs | 4 +- .../NotificationHubPoolTests.cs | 4 +- .../NotificationHubProxyTests.cs | 4 +- .../NotificationHubPushEngineTests.cs} | 14 +- .../PushServiceCollectionExtensionsTests.cs | 198 ++++++++++++++++++ test/Core.Test/Platform/Push/PushTypeTests.cs | 64 ++++++ ...ultiServicePushNotificationServiceTests.cs | 8 - ...ficationHubPushRegistrationServiceTests.cs | 4 +- ...trationServiceCollectionExtensionsTests.cs | 108 ++++++++++ .../RelayPushRegistrationServiceTests.cs | 2 +- test/Core.Test/Services/DeviceServiceTests.cs | 2 +- .../Factories/WebApplicationFactoryBase.cs | 2 +- 51 files changed, 849 insertions(+), 205 deletions(-) delete mode 100644 src/Core/Enums/PushType.cs rename src/Core/Platform/Push/{Services/AzureQueuePushNotificationService.cs => Engines/AzureQueuePushEngine.cs} (91%) rename src/Core/Platform/Push/{Services => Engines}/MultiServicePushNotificationService.cs (91%) rename src/Core/Platform/Push/{Services/NoopPushNotificationService.cs => Engines/NoopPushEngine.cs} (75%) rename src/Core/Platform/Push/{Services/NotificationsApiPushNotificationService.cs => Engines/NotificationsApiPushEngine.cs} (87%) rename src/Core/Platform/Push/{Services/RelayPushNotificationService.cs => Engines/RelayPushEngine.cs} (94%) rename src/Core/Platform/Push/{Services => }/IPushEngine.cs (76%) rename src/Core/Platform/Push/{Services => }/IPushNotificationService.cs (82%) rename src/Core/Platform/Push/{Services => }/IPushRelayer.cs (97%) rename src/Core/{ => Platform/Push}/NotificationHub/INotificationHubClientProxy.cs (82%) rename src/Core/{ => Platform/Push}/NotificationHub/INotificationHubPool.cs (81%) rename src/Core/{ => Platform/Push}/NotificationHub/NotificationHubClientProxy.cs (94%) rename src/Core/{ => Platform/Push}/NotificationHub/NotificationHubConnection.cs (99%) rename src/Core/{ => Platform/Push}/NotificationHub/NotificationHubPool.cs (98%) rename src/Core/{NotificationHub/NotificationHubPushNotificationService.cs => Platform/Push/NotificationHub/NotificationHubPushEngine.cs} (95%) create mode 100644 src/Core/Platform/Push/NotificationInfoAttribute.cs rename src/Core/Platform/Push/{Services => }/PushNotification.cs (96%) create mode 100644 src/Core/Platform/Push/PushServiceCollectionExtensions.cs create mode 100644 src/Core/Platform/Push/PushType.cs rename src/Core/Platform/{Push/Services => PushRegistration}/IPushRegistrationService.cs (79%) rename src/Core/Platform/{Push/Services => PushRegistration}/NoopPushRegistrationService.cs (86%) rename src/Core/{NotificationHub => Platform/PushRegistration}/NotificationHubPushRegistrationService.cs (99%) rename src/Core/{NotificationHub => Platform/PushRegistration}/PushRegistrationData.cs (92%) create mode 100644 src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs rename src/Core/Platform/{Push/Services => PushRegistration}/RelayPushRegistrationService.cs (95%) rename test/Core.Test/Platform/Push/{Services/AzureQueuePushNotificationServiceTests.cs => Engines/AzureQueuePushEngineTests.cs} (98%) rename test/Core.Test/Platform/Push/{Services/NotificationsApiPushNotificationServiceTests.cs => Engines/NotificationsApiPushEngineTests.cs} (97%) rename test/Core.Test/Platform/Push/{Services => Engines}/PushTestBase.cs (99%) rename test/Core.Test/Platform/Push/{Services/RelayPushNotificationServiceTests.cs => Engines/RelayPushEngineTests.cs} (98%) create mode 100644 test/Core.Test/Platform/Push/MultiServicePushNotificationServiceTests.cs rename test/Core.Test/{ => Platform/Push}/NotificationHub/NotificationHubConnectionTests.cs (98%) rename test/Core.Test/{ => Platform/Push}/NotificationHub/NotificationHubPoolTests.cs (98%) rename test/Core.Test/{ => Platform/Push}/NotificationHub/NotificationHubProxyTests.cs (93%) rename test/Core.Test/{NotificationHub/NotificationHubPushNotificationServiceTests.cs => Platform/Push/NotificationHub/NotificationHubPushEngineTests.cs} (98%) create mode 100644 test/Core.Test/Platform/Push/PushServiceCollectionExtensionsTests.cs create mode 100644 test/Core.Test/Platform/Push/PushTypeTests.cs delete mode 100644 test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs rename test/Core.Test/{NotificationHub => Platform/PushRegistration}/NotificationHubPushRegistrationServiceTests.cs (99%) create mode 100644 test/Core.Test/Platform/PushRegistration/PushRegistrationServiceCollectionExtensionsTests.cs rename test/Core.Test/Platform/{Push/Services => PushRegistration}/RelayPushRegistrationServiceTests.cs (95%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 88cfc71256..2f1c5f18fb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -92,6 +92,8 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev **/.dockerignore @bitwarden/team-platform-dev **/Dockerfile @bitwarden/team-platform-dev **/entrypoint.sh @bitwarden/team-platform-dev +# The PushType enum is expected to be editted by anyone without need for Platform review +src/Core/Platform/Push/PushType.cs # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json diff --git a/src/Api/Models/Request/DeviceRequestModels.cs b/src/Api/Models/Request/DeviceRequestModels.cs index 397d4e27df..11600a0195 100644 --- a/src/Api/Models/Request/DeviceRequestModels.cs +++ b/src/Api/Models/Request/DeviceRequestModels.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Utilities; namespace Bit.Api.Models.Request; diff --git a/src/Api/Platform/Push/Controllers/PushController.cs b/src/Api/Platform/Push/Controllers/PushController.cs index 88aec18be3..14c0a20636 100644 --- a/src/Api/Platform/Push/Controllers/PushController.cs +++ b/src/Api/Platform/Push/Controllers/PushController.cs @@ -6,9 +6,9 @@ using System.Text.Json; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; diff --git a/src/Core/Enums/PushType.cs b/src/Core/Enums/PushType.cs deleted file mode 100644 index 07c40f94a2..0000000000 --- a/src/Core/Enums/PushType.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Bit.Core.Enums; - -public enum PushType : byte -{ - SyncCipherUpdate = 0, - SyncCipherCreate = 1, - SyncLoginDelete = 2, - SyncFolderDelete = 3, - SyncCiphers = 4, - - SyncVault = 5, - SyncOrgKeys = 6, - SyncFolderCreate = 7, - SyncFolderUpdate = 8, - SyncCipherDelete = 9, - SyncSettings = 10, - - LogOut = 11, - - SyncSendCreate = 12, - SyncSendUpdate = 13, - SyncSendDelete = 14, - - AuthRequest = 15, - AuthRequestResponse = 16, - - SyncOrganizations = 17, - SyncOrganizationStatusChanged = 18, - SyncOrganizationCollectionSettingChanged = 19, - - Notification = 20, - NotificationStatus = 21, - - RefreshSecurityTasks = 22 -} diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index 83c6f577d4..e235d05b13 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -1,9 +1,11 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.NotificationCenter.Enums; namespace Bit.Core.Models; +// New push notification payload models should not be defined in this file +// they should instead be defined in file owned by your team. + public class PushNotificationData { public PushNotificationData(PushType type, T payload, string? contextId) diff --git a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs similarity index 91% rename from src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs rename to src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs index 94a20f1971..e8c8790c64 100644 --- a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.Text.Json; +using System.Text.Json; using Azure.Storage.Queues; using Bit.Core.Context; using Bit.Core.Enums; @@ -13,17 +12,16 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.Platform.Push.Internal; -public class AzureQueuePushNotificationService : IPushEngine +public class AzureQueuePushEngine : IPushEngine { private readonly QueueClient _queueClient; private readonly IHttpContextAccessor _httpContextAccessor; - public AzureQueuePushNotificationService( + public AzureQueuePushEngine( [FromKeyedServices("notifications")] QueueClient queueClient, IHttpContextAccessor httpContextAccessor, IGlobalSettings globalSettings, - ILogger logger, - TimeProvider timeProvider) + ILogger logger) { _queueClient = queueClient; _httpContextAccessor = httpContextAccessor; diff --git a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs b/src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs similarity index 91% rename from src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs rename to src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs index 404b153fa3..1dbd2c83e5 100644 --- a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Core.Vault.Entities; using Microsoft.Extensions.Logging; @@ -8,7 +7,7 @@ namespace Bit.Core.Platform.Push.Internal; public class MultiServicePushNotificationService : IPushNotificationService { - private readonly IEnumerable _services; + private readonly IPushEngine[] _services; public Guid InstallationId { get; } @@ -22,7 +21,8 @@ public class MultiServicePushNotificationService : IPushNotificationService GlobalSettings globalSettings, TimeProvider timeProvider) { - _services = services; + // Filter out any NoopPushEngine's + _services = [.. services.Where(engine => engine is not NoopPushEngine)]; Logger = logger; Logger.LogInformation("Hub services: {Services}", _services.Count()); diff --git a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs b/src/Core/Platform/Push/Engines/NoopPushEngine.cs similarity index 75% rename from src/Core/Platform/Push/Services/NoopPushNotificationService.cs rename to src/Core/Platform/Push/Engines/NoopPushEngine.cs index e6f71de006..029d6dd556 100644 --- a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/NoopPushEngine.cs @@ -1,10 +1,9 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Vault.Entities; namespace Bit.Core.Platform.Push.Internal; -internal class NoopPushNotificationService : IPushEngine +internal class NoopPushEngine : IPushEngine { public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds) => Task.CompletedTask; diff --git a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs similarity index 87% rename from src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs rename to src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs index 5e0d584ba8..add53278ff 100644 --- a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Context; +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Services; @@ -8,23 +7,22 @@ using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -// This service is not in the `Internal` namespace because it has direct external references. -namespace Bit.Core.Platform.Push; +namespace Bit.Core.Platform.Push.Internal; /// /// Sends non-mobile push notifications to the Azure Queue Api, later received by Notifications Api. /// Used by Cloud-Hosted environments. /// Received by AzureQueueHostedService message receiver in Notifications project. /// -public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushEngine +public class NotificationsApiPushEngine : BaseIdentityClientService, IPushEngine { private readonly IHttpContextAccessor _httpContextAccessor; - public NotificationsApiPushNotificationService( + public NotificationsApiPushEngine( IHttpClientFactory httpFactory, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger) : base( httpFactory, globalSettings.BaseServiceUri.InternalNotifications, diff --git a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs b/src/Core/Platform/Push/Engines/RelayPushEngine.cs similarity index 94% rename from src/Core/Platform/Push/Services/RelayPushNotificationService.cs rename to src/Core/Platform/Push/Engines/RelayPushEngine.cs index 9f2289b864..66b0229315 100644 --- a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/RelayPushEngine.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Context; +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.IdentityServer; using Bit.Core.Models; @@ -19,18 +18,18 @@ namespace Bit.Core.Platform.Push.Internal; /// Used by Self-Hosted environments. /// Received by PushController endpoint in Api project. /// -public class RelayPushNotificationService : BaseIdentityClientService, IPushEngine +public class RelayPushEngine : BaseIdentityClientService, IPushEngine { private readonly IDeviceRepository _deviceRepository; private readonly IHttpContextAccessor _httpContextAccessor; - public RelayPushNotificationService( + public RelayPushEngine( IHttpClientFactory httpFactory, IDeviceRepository deviceRepository, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger) : base( httpFactory, globalSettings.PushRelayBaseUri, diff --git a/src/Core/Platform/Push/Services/IPushEngine.cs b/src/Core/Platform/Push/IPushEngine.cs similarity index 76% rename from src/Core/Platform/Push/Services/IPushEngine.cs rename to src/Core/Platform/Push/IPushEngine.cs index bde4ddaf4b..ca00dae3ad 100644 --- a/src/Core/Platform/Push/Services/IPushEngine.cs +++ b/src/Core/Platform/Push/IPushEngine.cs @@ -1,8 +1,7 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Vault.Entities; -namespace Bit.Core.Platform.Push; +namespace Bit.Core.Platform.Push.Internal; public interface IPushEngine { diff --git a/src/Core/Platform/Push/Services/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs similarity index 82% rename from src/Core/Platform/Push/Services/IPushNotificationService.cs rename to src/Core/Platform/Push/IPushNotificationService.cs index c6da6cf6b7..339ce5a917 100644 --- a/src/Core/Platform/Push/Services/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Enums; using Bit.Core.Models; @@ -10,10 +9,27 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.Platform.Push; +/// +/// Used to Push notifications to end-user devices. +/// +/// +/// New notifications should not be wired up inside this service. You may either directly call the +/// method in your service to send your notification or if you want your notification +/// sent by other teams you can make an extension method on this service with a well typed definition +/// of your notification. You may also make your own service that injects this and exposes methods for each of +/// your notifications. +/// public interface IPushNotificationService { + private const string ServiceDeprecation = "Do not use the services exposed here, instead use your own services injected in your service."; + + [Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")] Guid InstallationId { get; } + + [Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")] TimeProvider TimeProvider { get; } + + [Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")] ILogger Logger { get; } #region Legacy method, to be removed soon. @@ -80,7 +96,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -94,7 +112,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -108,7 +128,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -122,7 +144,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -136,7 +160,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -150,7 +176,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = excludeCurrentContextFromPush, }); @@ -231,7 +259,9 @@ public interface IPushNotificationService ClientType = notification.ClientType, UserId = notification.UserId, OrganizationId = notification.OrganizationId, +#pragma warning disable BWP0001 // Type or member is obsolete InstallationId = notification.Global ? InstallationId : null, +#pragma warning restore BWP0001 // Type or member is obsolete TaskId = notification.TaskId, Title = notification.Title, Body = notification.Body, @@ -246,7 +276,9 @@ public interface IPushNotificationService { // TODO: Think about this a bit more target = NotificationTarget.Installation; +#pragma warning disable BWP0001 // Type or member is obsolete targetId = InstallationId; +#pragma warning restore BWP0001 // Type or member is obsolete } else if (notification.UserId.HasValue) { @@ -260,7 +292,9 @@ public interface IPushNotificationService } else { +#pragma warning disable BWP0001 // Type or member is obsolete Logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id); +#pragma warning restore BWP0001 // Type or member is obsolete return Task.CompletedTask; } @@ -285,7 +319,9 @@ public interface IPushNotificationService ClientType = notification.ClientType, UserId = notification.UserId, OrganizationId = notification.OrganizationId, +#pragma warning disable BWP0001 // Type or member is obsolete InstallationId = notification.Global ? InstallationId : null, +#pragma warning restore BWP0001 // Type or member is obsolete TaskId = notification.TaskId, Title = notification.Title, Body = notification.Body, @@ -302,7 +338,9 @@ public interface IPushNotificationService { // TODO: Think about this a bit more target = NotificationTarget.Installation; +#pragma warning disable BWP0001 // Type or member is obsolete targetId = InstallationId; +#pragma warning restore BWP0001 // Type or member is obsolete } else if (notification.UserId.HasValue) { @@ -316,7 +354,9 @@ public interface IPushNotificationService } else { +#pragma warning disable BWP0001 // Type or member is obsolete Logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id); +#pragma warning restore BWP0001 // Type or member is obsolete return Task.CompletedTask; } @@ -398,7 +438,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -406,6 +448,12 @@ public interface IPushNotificationService Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds); + /// + /// Pushes a notification to devices based on the settings given to us in . + /// + /// The type of the payload to be sent along with the notification. + /// + /// A task that is NOT guarunteed to have sent the notification by the time the task resolves. Task PushAsync(PushNotification pushNotification) where T : class; } diff --git a/src/Core/Platform/Push/Services/IPushRelayer.cs b/src/Core/Platform/Push/IPushRelayer.cs similarity index 97% rename from src/Core/Platform/Push/Services/IPushRelayer.cs rename to src/Core/Platform/Push/IPushRelayer.cs index fde0a521f3..1fb75e0dfc 100644 --- a/src/Core/Platform/Push/Services/IPushRelayer.cs +++ b/src/Core/Platform/Push/IPushRelayer.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; namespace Bit.Core.Platform.Push.Internal; diff --git a/src/Core/NotificationHub/INotificationHubClientProxy.cs b/src/Core/Platform/Push/NotificationHub/INotificationHubClientProxy.cs similarity index 82% rename from src/Core/NotificationHub/INotificationHubClientProxy.cs rename to src/Core/Platform/Push/NotificationHub/INotificationHubClientProxy.cs index 78eb0206d6..8b765d209b 100644 --- a/src/Core/NotificationHub/INotificationHubClientProxy.cs +++ b/src/Core/Platform/Push/NotificationHub/INotificationHubClientProxy.cs @@ -1,8 +1,6 @@ using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public interface INotificationHubProxy { diff --git a/src/Core/NotificationHub/INotificationHubPool.cs b/src/Core/Platform/Push/NotificationHub/INotificationHubPool.cs similarity index 81% rename from src/Core/NotificationHub/INotificationHubPool.cs rename to src/Core/Platform/Push/NotificationHub/INotificationHubPool.cs index 25a31d62f4..3d5767623b 100644 --- a/src/Core/NotificationHub/INotificationHubPool.cs +++ b/src/Core/Platform/Push/NotificationHub/INotificationHubPool.cs @@ -1,8 +1,6 @@ using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public interface INotificationHubPool { diff --git a/src/Core/NotificationHub/NotificationHubClientProxy.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubClientProxy.cs similarity index 94% rename from src/Core/NotificationHub/NotificationHubClientProxy.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubClientProxy.cs index b47069fe21..026f3179d1 100644 --- a/src/Core/NotificationHub/NotificationHubClientProxy.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubClientProxy.cs @@ -1,8 +1,6 @@ using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public class NotificationHubClientProxy : INotificationHubProxy { diff --git a/src/Core/NotificationHub/NotificationHubConnection.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubConnection.cs similarity index 99% rename from src/Core/NotificationHub/NotificationHubConnection.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubConnection.cs index a61f2ded8f..22c1668506 100644 --- a/src/Core/NotificationHub/NotificationHubConnection.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubConnection.cs @@ -6,9 +6,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public class NotificationHubConnection { diff --git a/src/Core/NotificationHub/NotificationHubPool.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs similarity index 98% rename from src/Core/NotificationHub/NotificationHubPool.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs index 38192c11fc..c3dc47809f 100644 --- a/src/Core/NotificationHub/NotificationHubPool.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs @@ -3,9 +3,7 @@ using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public class NotificationHubPool : INotificationHubPool { diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubPushEngine.cs similarity index 95% rename from src/Core/NotificationHub/NotificationHubPushNotificationService.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubPushEngine.cs index 81ec82a25d..1d1eb2ef70 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubPushEngine.cs @@ -1,28 +1,23 @@ -#nullable enable -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Data; -using Bit.Core.Platform.Push; -using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; /// /// Sends mobile push notifications to the Azure Notification Hub. /// Used by Cloud-Hosted environments. /// Received by Firebase for Android or APNS for iOS. /// -public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer +public class NotificationHubPushEngine : IPushEngine, IPushRelayer { private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly IHttpContextAccessor _httpContextAccessor; @@ -30,11 +25,11 @@ public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer private readonly INotificationHubPool _notificationHubPool; private readonly ILogger _logger; - public NotificationHubPushNotificationService( + public NotificationHubPushEngine( IInstallationDeviceRepository installationDeviceRepository, INotificationHubPool notificationHubPool, IHttpContextAccessor httpContextAccessor, - ILogger logger, + ILogger logger, IGlobalSettings globalSettings) { _installationDeviceRepository = installationDeviceRepository; diff --git a/src/Core/Platform/Push/NotificationInfoAttribute.cs b/src/Core/Platform/Push/NotificationInfoAttribute.cs new file mode 100644 index 0000000000..ff134f5fda --- /dev/null +++ b/src/Core/Platform/Push/NotificationInfoAttribute.cs @@ -0,0 +1,44 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Platform.Push; + +/// +/// Used to annotate information about a given . +/// +[AttributeUsage(AttributeTargets.Field)] +public class NotificationInfoAttribute : Attribute +{ + // Once upon a time we can feed this information into a C# analyzer to make sure that we validate + // the callsites of IPushNotificationService.PushAsync uses the correct payload type for the notification type + // for now this only exists as forced documentation to teams who create a push type. + + // It's especially on purpose that we allow ourselves to take a type name via just the string, + // this allows teams to make a push type that is only sent with a payload that exists in a separate assembly than + // this one. + + public NotificationInfoAttribute(string team, Type payloadType) + // It should be impossible to reference an unnamed type for an attributes constructor so this assertion should be safe. + : this(team, payloadType.FullName!) + { + Team = team; + } + + public NotificationInfoAttribute(string team, string payloadTypeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(team); + ArgumentException.ThrowIfNullOrWhiteSpace(payloadTypeName); + + Team = team; + PayloadTypeName = payloadTypeName; + } + + /// + /// The name of the team that owns this . + /// + public string Team { get; } + + /// + /// The fully qualified type name of the payload that should be used when sending a notification of this type. + /// + public string PayloadTypeName { get; } +} diff --git a/src/Core/Platform/Push/Services/PushNotification.cs b/src/Core/Platform/Push/PushNotification.cs similarity index 96% rename from src/Core/Platform/Push/Services/PushNotification.cs rename to src/Core/Platform/Push/PushNotification.cs index e1d3f44cd8..3150b854a4 100644 --- a/src/Core/Platform/Push/Services/PushNotification.cs +++ b/src/Core/Platform/Push/PushNotification.cs @@ -6,6 +6,9 @@ namespace Bit.Core.Platform.Push; /// /// Contains constants for all the available targets for a given notification. /// +/// +/// Please reach out to the Platform team if you need a new target added. +/// public enum NotificationTarget { /// diff --git a/src/Core/Platform/Push/PushServiceCollectionExtensions.cs b/src/Core/Platform/Push/PushServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b54ae64c08 --- /dev/null +++ b/src/Core/Platform/Push/PushServiceCollectionExtensions.cs @@ -0,0 +1,82 @@ +using Azure.Storage.Queues; +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for adding the Push feature. +/// +public static class PushServiceCollectionExtensions +{ + /// + /// Adds a to the services that can be used to send push notifications to + /// end user devices. This method is safe to be ran multiple time provided does not + /// change between calls. + /// + /// The to add services to. + /// The to use to configure services. + /// The for additional chaining. + public static IServiceCollection AddPush(this IServiceCollection services, GlobalSettings globalSettings) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(globalSettings); + + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + + if (globalSettings.SelfHosted) + { + if (globalSettings.Installation.Id == Guid.Empty) + { + throw new InvalidOperationException("Installation Id must be set for self-hosted installations."); + } + + if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && + CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) + { + // TODO: We should really define the HttpClient we will use here + services.AddHttpClient(); + services.AddHttpContextAccessor(); + // We also depend on IDeviceRepository but don't explicitly add it right now. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + + if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && + CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) + { + // TODO: We should really define the HttpClient we will use here + services.AddHttpClient(); + services.AddHttpContextAccessor(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + } + else + { + services.TryAddSingleton(); + services.AddHttpContextAccessor(); + + // We also depend on IInstallationDeviceRepository but don't explicitly add it right now. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + services.TryAddSingleton(); + + if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) + { + services.TryAddKeyedSingleton("notifications", static (sp, _) => + { + var gs = sp.GetRequiredService(); + return new QueueClient(gs.Notifications.ConnectionString, "notifications"); + }); + + // We not IHttpContextAccessor will be added above, no need to do it here. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + } + + return services; + } +} diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs new file mode 100644 index 0000000000..7fcb60b4ef --- /dev/null +++ b/src/Core/Platform/Push/PushType.cs @@ -0,0 +1,93 @@ +using Bit.Core.Platform.Push; + +// TODO: This namespace should change to `Bit.Core.Platform.Push` +namespace Bit.Core.Enums; + +/// +/// +/// +/// +/// +/// When adding a new enum member you must annotate it with a +/// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced +/// in . +/// +/// +/// You may and are +/// +/// +public enum PushType : byte +{ + // When adding a new enum member you must annotate it with a NotificationInfoAttribute this is enforced with a unit + // test. It is preferred that you do NOT add new usings for the type referenced for the payload. You are also + // encouraged to define the payload type in your own teams owned code. + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncCipherUpdate = 0, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncCipherCreate = 1, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncLoginDelete = 2, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))] + SyncFolderDelete = 3, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] + SyncCiphers = 4, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + SyncVault = 5, + + [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.UserPushNotification))] + SyncOrgKeys = 6, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))] + SyncFolderCreate = 7, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))] + SyncFolderUpdate = 8, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncCipherDelete = 9, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + SyncSettings = 10, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + LogOut = 11, + + [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] + SyncSendCreate = 12, + + [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] + SyncSendUpdate = 13, + + [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] + SyncSendDelete = 14, + + [NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))] + AuthRequest = 15, + + [NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))] + AuthRequestResponse = 16, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + SyncOrganizations = 17, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.OrganizationStatusPushNotification))] + SyncOrganizationStatusChanged = 18, + + [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.OrganizationCollectionManagementPushNotification))] + SyncOrganizationCollectionSettingChanged = 19, + + [NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))] + Notification = 20, + + [NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))] + NotificationStatus = 21, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] + RefreshSecurityTasks = 22, +} diff --git a/src/Core/Platform/Push/Services/IPushRegistrationService.cs b/src/Core/Platform/PushRegistration/IPushRegistrationService.cs similarity index 79% rename from src/Core/Platform/Push/Services/IPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/IPushRegistrationService.cs index 8e34e5e316..d650842f32 100644 --- a/src/Core/Platform/Push/Services/IPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/IPushRegistrationService.cs @@ -1,10 +1,10 @@ -#nullable enable - -using Bit.Core.Enums; -using Bit.Core.NotificationHub; +using Bit.Core.Enums; +using Bit.Core.Platform.PushRegistration; +// TODO: Change this namespace to `Bit.Core.Platform.PushRegistration namespace Bit.Core.Platform.Push; + public interface IPushRegistrationService { Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId, string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId); diff --git a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs b/src/Core/Platform/PushRegistration/NoopPushRegistrationService.cs similarity index 86% rename from src/Core/Platform/Push/Services/NoopPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/NoopPushRegistrationService.cs index 32efc95ce6..0aebcbf1f3 100644 --- a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/NoopPushRegistrationService.cs @@ -1,9 +1,7 @@ -#nullable enable +using Bit.Core.Enums; +using Bit.Core.Platform.Push; -using Bit.Core.Enums; -using Bit.Core.NotificationHub; - -namespace Bit.Core.Platform.Push.Internal; +namespace Bit.Core.Platform.PushRegistration.Internal; public class NoopPushRegistrationService : IPushRegistrationService { diff --git a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs b/src/Core/Platform/PushRegistration/NotificationHubPushRegistrationService.cs similarity index 99% rename from src/Core/NotificationHub/NotificationHubPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/NotificationHubPushRegistrationService.cs index dc494eecd6..ee02e2bdf1 100644 --- a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/NotificationHubPushRegistrationService.cs @@ -6,14 +6,13 @@ using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.PushRegistration.Internal; public class NotificationHubPushRegistrationService : IPushRegistrationService { diff --git a/src/Core/NotificationHub/PushRegistrationData.cs b/src/Core/Platform/PushRegistration/PushRegistrationData.cs similarity index 92% rename from src/Core/NotificationHub/PushRegistrationData.cs rename to src/Core/Platform/PushRegistration/PushRegistrationData.cs index c11ee7be23..844de4e1be 100644 --- a/src/Core/NotificationHub/PushRegistrationData.cs +++ b/src/Core/Platform/PushRegistration/PushRegistrationData.cs @@ -1,6 +1,4 @@ -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.PushRegistration; public record struct WebPushRegistrationData { diff --git a/src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs b/src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..841902c964 --- /dev/null +++ b/src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration.Internal; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for adding the Push Registration feature. +/// +public static class PushRegistrationServiceCollectionExtensions +{ + /// + /// Adds a to the service collection. + /// + /// The to add services to. + /// The for chaining. + public static IServiceCollection AddPushRegistration(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // TODO: Should add feature that brings in IInstallationDeviceRepository once that is featurized + + // Register all possible variants under there concrete type. + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.AddHttpClient(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(static sp => + { + var globalSettings = sp.GetRequiredService(); + + if (globalSettings.SelfHosted) + { + if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && + CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) + { + return sp.GetRequiredService(); + } + + return sp.GetRequiredService(); + } + + return sp.GetRequiredService(); + }); + + return services; + } +} diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs similarity index 95% rename from src/Core/Platform/Push/Services/RelayPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs index 20e405935b..96a259ecf8 100644 --- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs @@ -1,14 +1,12 @@ -#nullable enable - -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.IdentityServer; using Bit.Core.Models.Api; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.Platform.Push.Internal; +namespace Bit.Core.Platform.PushRegistration.Internal; public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService { diff --git a/src/Core/Services/IDeviceService.cs b/src/Core/Services/IDeviceService.cs index 78739e081d..ca13047c77 100644 --- a/src/Core/Services/IDeviceService.cs +++ b/src/Core/Services/IDeviceService.cs @@ -1,6 +1,6 @@ using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Entities; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.PushRegistration; namespace Bit.Core.Services; diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs index 931dfccdec..ea6e77aa8c 100644 --- a/src/Core/Services/Implementations/DeviceService.cs +++ b/src/Core/Services/Implementations/DeviceService.cs @@ -3,8 +3,8 @@ using Bit.Core.Auth.Utilities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Repositories; using Bit.Core.Settings; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 51383d650e..0dd5431dd7 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; -using Azure.Storage.Queues; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -35,11 +34,10 @@ using Bit.Core.Identity; using Bit.Core.IdentityServer; using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; -using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; using Bit.Core.Platform; using Bit.Core.Platform.Push; -using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Repositories; using Bit.Core.Resources; using Bit.Core.SecretsManager.Repositories; @@ -279,46 +277,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); } - services.TryAddSingleton(TimeProvider.System); - - services.AddSingleton(); - if (globalSettings.SelfHosted) - { - if (globalSettings.Installation.Id == Guid.Empty) - { - throw new InvalidOperationException("Installation Id must be set for self-hosted installations."); - } - - if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && - CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } - - if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && - CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } - } - else - { - services.AddSingleton(); - services.AddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddSingleton(); - if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) - { - services.AddKeyedSingleton("notifications", - (_, _) => new QueueClient(globalSettings.Notifications.ConnectionString, "notifications")); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } - } + services.AddPush(globalSettings); + services.AddPushRegistration(); if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString)) { diff --git a/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs b/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs index 32d6389616..1f0ecd4835 100644 --- a/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs +++ b/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs @@ -8,8 +8,8 @@ using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Data; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Installations; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using NSubstitute; using Xunit; diff --git a/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs b/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs index d6a26255e9..e74ebc4c03 100644 --- a/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs +++ b/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs @@ -4,8 +4,8 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Api; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs similarity index 98% rename from test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs index b223ef7252..961d7cd770 100644 --- a/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs @@ -22,18 +22,18 @@ using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Platform.Push.Services; +namespace Bit.Core.Test.Platform.Push.Engines; [QueueClientCustomize] [SutProviderCustomize] -public class AzureQueuePushNotificationServiceTests +public class AzureQueuePushEngineTests { private static readonly Guid _deviceId = Guid.Parse("c4730f80-caaa-4772-97bd-5c0d23a2baa3"); private static readonly string _deviceIdentifier = "test_device_identifier"; private readonly FakeTimeProvider _fakeTimeProvider; private readonly Core.Settings.GlobalSettings _globalSettings = new(); - public AzureQueuePushNotificationServiceTests() + public AzureQueuePushEngineTests() { _fakeTimeProvider = new(); _fakeTimeProvider.SetUtcNow(DateTime.UtcNow); @@ -771,12 +771,11 @@ public class AzureQueuePushNotificationServiceTests var globalSettings = new Core.Settings.GlobalSettings(); - var sut = new AzureQueuePushNotificationService( + var sut = new AzureQueuePushEngine( queueClient, httpContextAccessor, globalSettings, - NullLogger.Instance, - _fakeTimeProvider + NullLogger.Instance ); await test(new EngineWrapper(sut, _fakeTimeProvider, _globalSettings.Installation.Id)); diff --git a/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Engines/NotificationsApiPushEngineTests.cs similarity index 97% rename from test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Engines/NotificationsApiPushEngineTests.cs index 5231456d63..c61c2f37d0 100644 --- a/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Engines/NotificationsApiPushEngineTests.cs @@ -2,16 +2,16 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.NotificationCenter.Entities; -using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Microsoft.Extensions.Logging.Abstractions; -namespace Bit.Core.Test.Platform.Push.Services; +namespace Bit.Core.Test.Platform.Push.Engines; -public class NotificationsApiPushNotificationServiceTests : PushTestBase +public class NotificationsApiPushEngineTests : PushTestBase { - public NotificationsApiPushNotificationServiceTests() + public NotificationsApiPushEngineTests() { GlobalSettings.BaseServiceUri.InternalNotifications = "https://localhost:7777"; GlobalSettings.BaseServiceUri.InternalIdentity = "https://localhost:8888"; @@ -21,11 +21,11 @@ public class NotificationsApiPushNotificationServiceTests : PushTestBase protected override IPushEngine CreateService() { - return new NotificationsApiPushNotificationService( + return new NotificationsApiPushEngine( HttpClientFactory, GlobalSettings, HttpContextAccessor, - NullLogger.Instance + NullLogger.Instance ); } diff --git a/test/Core.Test/Platform/Push/Services/PushTestBase.cs b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs similarity index 99% rename from test/Core.Test/Platform/Push/Services/PushTestBase.cs rename to test/Core.Test/Platform/Push/Engines/PushTestBase.cs index 3ff09f1064..9097028370 100644 --- a/test/Core.Test/Platform/Push/Services/PushTestBase.cs +++ b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs @@ -10,6 +10,7 @@ using Bit.Core.Enums; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Enums; using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -22,6 +23,8 @@ using NSubstitute; using RichardSzalay.MockHttp; using Xunit; +namespace Bit.Core.Test.Platform.Push.Engines; + public class EngineWrapper(IPushEngine pushEngine, FakeTimeProvider fakeTimeProvider, Guid installationId) : IPushNotificationService { public Guid InstallationId { get; } = installationId; diff --git a/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Engines/RelayPushEngineTests.cs similarity index 98% rename from test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Engines/RelayPushEngineTests.cs index ddad05eda0..010ad40d13 100644 --- a/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Engines/RelayPushEngineTests.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Bit.Core.NotificationCenter.Entities; -using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Settings; @@ -15,7 +14,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using NSubstitute; -namespace Bit.Core.Test.Platform.Push.Services; +namespace Bit.Core.Test.Platform.Push.Engines; public class RelayPushNotificationServiceTests : PushTestBase { @@ -39,12 +38,12 @@ public class RelayPushNotificationServiceTests : PushTestBase protected override IPushEngine CreateService() { - return new RelayPushNotificationService( + return new RelayPushEngine( HttpClientFactory, _deviceRepository, GlobalSettings, HttpContextAccessor, - NullLogger.Instance + NullLogger.Instance ); } diff --git a/test/Core.Test/Platform/Push/MultiServicePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/MultiServicePushNotificationServiceTests.cs new file mode 100644 index 0000000000..f0143bae51 --- /dev/null +++ b/test/Core.Test/Platform/Push/MultiServicePushNotificationServiceTests.cs @@ -0,0 +1,57 @@ +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Platform.Push; + +public class MultiServicePushNotificationServiceTests +{ + private readonly IPushEngine _fakeEngine1; + private readonly IPushEngine _fakeEngine2; + + private readonly MultiServicePushNotificationService _sut; + + public MultiServicePushNotificationServiceTests() + { + _fakeEngine1 = Substitute.For(); + _fakeEngine2 = Substitute.For(); + + _sut = new MultiServicePushNotificationService( + [_fakeEngine1, _fakeEngine2], + NullLogger.Instance, + new GlobalSettings(), + new FakeTimeProvider() + ); + } + +#if DEBUG // This test requires debug code in the sut to work properly + [Fact] + public async Task PushAsync_CallsAllEngines() + { + var notification = new PushNotification + { + Target = NotificationTarget.User, + TargetId = Guid.NewGuid(), + Type = PushType.AuthRequest, + Payload = new { }, + ExcludeCurrentContext = false, + }; + + await _sut.PushAsync(notification); + + await _fakeEngine1 + .Received(1) + .PushAsync(Arg.Is>(n => ReferenceEquals(n, notification))); + + await _fakeEngine2 + .Received(1) + .PushAsync(Arg.Is>(n => ReferenceEquals(n, notification))); + } + +#endif +} diff --git a/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubConnectionTests.cs similarity index 98% rename from test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs rename to test/Core.Test/Platform/Push/NotificationHub/NotificationHubConnectionTests.cs index fc76e5c1b7..51ba3a10c6 100644 --- a/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs +++ b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubConnectionTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Settings; using Bit.Core.Utilities; using Xunit; -namespace Bit.Core.Test.NotificationHub; +namespace Bit.Core.Test.Platform.Push.NotificationHub; public class NotificationHubConnectionTests { diff --git a/test/Core.Test/NotificationHub/NotificationHubPoolTests.cs b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubPoolTests.cs similarity index 98% rename from test/Core.Test/NotificationHub/NotificationHubPoolTests.cs rename to test/Core.Test/Platform/Push/NotificationHub/NotificationHubPoolTests.cs index dd9afb867e..5547ab55dd 100644 --- a/test/Core.Test/NotificationHub/NotificationHubPoolTests.cs +++ b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubPoolTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; @@ -6,7 +6,7 @@ using NSubstitute; using Xunit; using static Bit.Core.Settings.GlobalSettings; -namespace Bit.Core.Test.NotificationHub; +namespace Bit.Core.Test.Platform.Push.NotificationHub; public class NotificationHubPoolTests { diff --git a/test/Core.Test/NotificationHub/NotificationHubProxyTests.cs b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubProxyTests.cs similarity index 93% rename from test/Core.Test/NotificationHub/NotificationHubProxyTests.cs rename to test/Core.Test/Platform/Push/NotificationHub/NotificationHubProxyTests.cs index b2e9c4f9f3..846b6e5fc4 100644 --- a/test/Core.Test/NotificationHub/NotificationHubProxyTests.cs +++ b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubProxyTests.cs @@ -1,11 +1,11 @@ using AutoFixture; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push.Internal; using Bit.Test.Common.AutoFixture; using Microsoft.Azure.NotificationHubs; using NSubstitute; using Xunit; -namespace Bit.Core.Test.NotificationHub; +namespace Bit.Core.Test.Platform.Push.NotificationHub; public class NotificationHubProxyTests { diff --git a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubPushEngineTests.cs similarity index 98% rename from test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/NotificationHub/NotificationHubPushEngineTests.cs index 54a6f84339..a32b112675 100644 --- a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/NotificationHub/NotificationHubPushEngineTests.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using Bit.Core.Auth.Entities; using Bit.Core.Context; @@ -7,10 +6,11 @@ using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Enums; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Core.Test.Platform.Push.Engines; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Test.Common.AutoFixture; @@ -22,7 +22,7 @@ using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; -namespace Bit.Core.Test.NotificationHub; +namespace Bit.Core.Test.Platform.Push.NotificationHub; [SutProviderCustomize] [NotificationStatusCustomize] @@ -621,11 +621,11 @@ public class NotificationHubPushNotificationServiceTests fakeTimeProvider.SetUtcNow(_now); - var sut = new NotificationHubPushNotificationService( + var sut = new NotificationHubPushEngine( installationDeviceRepository, notificationHubPool, httpContextAccessor, - NullLogger.Instance, + NullLogger.Instance, globalSettings ); @@ -676,7 +676,7 @@ public class NotificationHubPushNotificationServiceTests }; private static async Task AssertSendTemplateNotificationAsync( - SutProvider sutProvider, PushType type, object payload, string tag) + SutProvider sutProvider, PushType type, object payload, string tag) { await sutProvider.GetDependency() .Received(1) diff --git a/test/Core.Test/Platform/Push/PushServiceCollectionExtensionsTests.cs b/test/Core.Test/Platform/Push/PushServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..0ab1a91195 --- /dev/null +++ b/test/Core.Test/Platform/Push/PushServiceCollectionExtensionsTests.cs @@ -0,0 +1,198 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.UserKey; +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Repositories; +using Bit.Core.Repositories.Noop; +using Bit.Core.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Xunit; + +namespace Bit.Core.Test.Platform.Push; + +public class PushServiceCollectionExtensionsTests +{ + [Fact] + public void AddPush_SelfHosted_NoConfig_NoEngines() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + { "GlobalSettings:Installation:Id", Guid.NewGuid().ToString() }, + }); + + _ = services.GetRequiredService(); + var engines = services.GetServices(); + + Assert.Empty(engines); + } + + [Fact] + public void AddPush_SelfHosted_ConfiguredForRelay_RelayEngineAdded() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + { "GlobalSettings:Installation:Id", Guid.NewGuid().ToString() }, + { "GlobalSettings:Installation:Key", "some_key"}, + { "GlobalSettings:PushRelayBaseUri", "https://example.com" }, + }); + + _ = services.GetRequiredService(); + var engines = services.GetServices(); + + var engine = Assert.Single(engines); + Assert.IsType(engine); + } + + [Fact] + public void AddPush_SelfHosted_ConfiguredForApi_ApiEngineAdded() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + { "GlobalSettings:Installation:Id", Guid.NewGuid().ToString() }, + { "GlobalSettings:InternalIdentityKey", "some_key"}, + { "GlobalSettings:BaseServiceUri", "https://example.com" }, + }); + + _ = services.GetRequiredService(); + var engines = services.GetServices(); + + var engine = Assert.Single(engines); + Assert.IsType(engine); + } + + [Fact] + public void AddPush_SelfHosted_ConfiguredForRelayAndApi_TwoEnginesAdded() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + { "GlobalSettings:Installation:Id", Guid.NewGuid().ToString() }, + { "GlobalSettings:Installation:Key", "some_key"}, + { "GlobalSettings:PushRelayBaseUri", "https://example.com" }, + { "GlobalSettings:InternalIdentityKey", "some_key"}, + { "GlobalSettings:BaseServiceUri", "https://example.com" }, + }); + + _ = services.GetRequiredService(); + var engines = services.GetServices(); + + Assert.Collection( + engines, + e => Assert.IsType(e), + e => Assert.IsType(e) + ); + } + + [Fact] + public void AddPush_Cloud_NoConfig_AddsNotificationHub() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "false" }, + }); + + _ = services.GetRequiredService(); + var engines = services.GetServices(); + + var engine = Assert.Single(engines); + Assert.IsType(engine); + } + + [Fact] + public void AddPush_Cloud_HasNotificationConnectionString_TwoEngines() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "false" }, + { "GlobalSettings:Notifications:ConnectionString", "UseDevelopmentStorage=true" }, + }); + + _ = services.GetRequiredService(); + var engines = services.GetServices(); + + Assert.Collection( + engines, + e => Assert.IsType(e), + e => Assert.IsType(e) + ); + } + + [Fact] + public void AddPush_Cloud_CalledTwice_DoesNotAddServicesTwice() + { + var services = new ServiceCollection(); + + var config = new Dictionary + { + { "GlobalSettings:SelfHosted", "false" }, + { "GlobalSettings:Notifications:ConnectionString", "UseDevelopmentStorage=true" }, + }; + + AddServices(services, config); + + var initialCount = services.Count; + + // Add services again + AddServices(services, config); + + Assert.Equal(initialCount, services.Count); + } + + private static ServiceProvider Build(Dictionary initialData) + { + var services = new ServiceCollection(); + + AddServices(services, initialData); + + return services.BuildServiceProvider(); + } + + private static void AddServices(IServiceCollection services, Dictionary initialData) + { + // A minimal service collection is always expected to have logging, config, and global settings + // pre-registered. + + services.AddLogging(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(initialData) + .Build(); + + services.TryAddSingleton(config); + var globalSettings = new GlobalSettings(); + config.GetSection("GlobalSettings").Bind(globalSettings); + + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(globalSettings); + + // Temporary until AddPush can add it themselves directly. + services.TryAddSingleton(); + + // Temporary until AddPush can add it themselves directly. + services.TryAddSingleton(); + + services.AddPush(globalSettings); + } + + private class StubDeviceRepository : IDeviceRepository + { + public Task ClearPushTokenAsync(Guid id) => throw new NotImplementedException(); + public Task CreateAsync(Device obj) => throw new NotImplementedException(); + public Task DeleteAsync(Device obj) => throw new NotImplementedException(); + public Task GetByIdAsync(Guid id, Guid userId) => throw new NotImplementedException(); + public Task GetByIdAsync(Guid id) => throw new NotImplementedException(); + public Task GetByIdentifierAsync(string identifier) => throw new NotImplementedException(); + public Task GetByIdentifierAsync(string identifier, Guid userId) => throw new NotImplementedException(); + public Task> GetManyByUserIdAsync(Guid userId) => throw new NotImplementedException(); + public Task> GetManyByUserIdWithDeviceAuth(Guid userId) => throw new NotImplementedException(); + public Task ReplaceAsync(Device obj) => throw new NotImplementedException(); + public UpdateEncryptedDataForKeyRotation UpdateKeysForRotationAsync(Guid userId, IEnumerable devices) => throw new NotImplementedException(); + public Task UpsertAsync(Device obj) => throw new NotImplementedException(); + } +} diff --git a/test/Core.Test/Platform/Push/PushTypeTests.cs b/test/Core.Test/Platform/Push/PushTypeTests.cs new file mode 100644 index 0000000000..0d1e389410 --- /dev/null +++ b/test/Core.Test/Platform/Push/PushTypeTests.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using System.Reflection; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Xunit; + +namespace Bit.Core.Test.Platform.Push; + +public class PushTypeTests +{ + [Fact] + public void AllEnumMembersHaveUniqueValue() + { + // No enum member should use the same value as another named member. + + var usedNumbers = new HashSet(); + var enumMembers = Enum.GetValues(); + + foreach (var enumMember in enumMembers) + { + if (!usedNumbers.Add((byte)enumMember)) + { + Assert.Fail($"Enum number value ({(byte)enumMember}) on {enumMember} is already in use."); + } + } + } + + [Fact] + public void AllEnumMembersHaveNotificationInfoAttribute() + { + // Every enum member should be annotated with [NotificationInfo] + + foreach (var member in typeof(PushType).GetMembers(BindingFlags.Public | BindingFlags.Static)) + { + var notificationInfoAttribute = member.GetCustomAttribute(); + if (notificationInfoAttribute is null) + { + Assert.Fail($"PushType.{member.Name} is missing a required [NotificationInfo(\"team-name\", typeof(MyType))] attribute."); + } + } + } + + [Fact] + public void AllEnumValuesAreInSequence() + { + // There should not be any gaps in the numbers defined for an enum, that being if someone last defined 22 + // the next number used should be 23 not 24 or any other number. + + var sortedValues = Enum.GetValues() + .Order() + .ToArray(); + + Debug.Assert(sortedValues.Length > 0); + + var lastValue = sortedValues[0]; + + foreach (var value in sortedValues[1..]) + { + var expectedValue = ++lastValue; + + Assert.Equal(expectedValue, value); + } + } +} diff --git a/test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs deleted file mode 100644 index a1bc2c6547..0000000000 --- a/test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs +++ /dev/null @@ -1,8 +0,0 @@ -#nullable enable - -namespace Bit.Core.Test.Platform.Push.Services; - -public class MultiServicePushNotificationServiceTests -{ - // TODO: Can add a couple tests here -} diff --git a/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs b/test/Core.Test/Platform/PushRegistration/NotificationHubPushRegistrationServiceTests.cs similarity index 99% rename from test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs rename to test/Core.Test/Platform/PushRegistration/NotificationHubPushRegistrationServiceTests.cs index b30cd3dda8..43ac916ed6 100644 --- a/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs +++ b/test/Core.Test/Platform/PushRegistration/NotificationHubPushRegistrationServiceTests.cs @@ -1,6 +1,8 @@ #nullable enable using Bit.Core.Enums; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration; +using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/test/Core.Test/Platform/PushRegistration/PushRegistrationServiceCollectionExtensionsTests.cs b/test/Core.Test/Platform/PushRegistration/PushRegistrationServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..19760f93ab --- /dev/null +++ b/test/Core.Test/Platform/PushRegistration/PushRegistrationServiceCollectionExtensionsTests.cs @@ -0,0 +1,108 @@ +using Bit.Core.Platform.Push; +using Bit.Core.Platform.PushRegistration.Internal; +using Bit.Core.Repositories; +using Bit.Core.Repositories.Noop; +using Bit.Core.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Xunit; + +namespace Bit.Core.Test.Platform.PushRegistration; + +public class PushRegistrationServiceCollectionExtensionsTests +{ + [Fact] + public void AddPushRegistration_Cloud_CreatesNotificationHubRegistrationService() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "false" }, + }); + + var pushRegistrationService = services.GetRequiredService(); + Assert.IsType(pushRegistrationService); + } + + [Fact] + public void AddPushRegistration_SelfHosted_NoOtherConfig_ReturnsNoopRegistrationService() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + }); + + var pushRegistrationService = services.GetRequiredService(); + Assert.IsType(pushRegistrationService); + } + + [Fact] + public void AddPushRegistration_SelfHosted_RelayConfig_ReturnsRelayRegistrationService() + { + var services = Build(new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + { "GlobalSettings:PushRelayBaseUri", "https://example.com" }, + { "GlobalSettings:Installation:Key", "some_key" }, + }); + + var pushRegistrationService = services.GetRequiredService(); + Assert.IsType(pushRegistrationService); + } + + [Fact] + public void AddPushRegistration_MultipleTimes_NoAdditionalServices() + { + var services = new ServiceCollection(); + + var config = new Dictionary + { + { "GlobalSettings:SelfHosted", "true" }, + { "GlobalSettings:PushRelayBaseUri", "https://example.com" }, + { "GlobalSettings:Installation:Key", "some_key" }, + }; + + AddServices(services, config); + + // Add services again + services.AddPushRegistration(); + + var provider = services.BuildServiceProvider(); + + Assert.Single(provider.GetServices()); + } + + private static ServiceProvider Build(Dictionary initialData) + { + var services = new ServiceCollection(); + + AddServices(services, initialData); + + return services.BuildServiceProvider(); + } + + private static void AddServices(IServiceCollection services, Dictionary initialData) + { + // A minimal service collection is always expected to have logging, config, and global settings + // pre-registered. + + services.AddLogging(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(initialData) + .Build(); + + services.TryAddSingleton(config); + var globalSettings = new GlobalSettings(); + config.GetSection("GlobalSettings").Bind(globalSettings); + + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(globalSettings); + + + // Temporary until AddPushRegistration can add it themselves directly. + services.TryAddSingleton(); + + services.AddPushRegistration(); + } +} diff --git a/test/Core.Test/Platform/Push/Services/RelayPushRegistrationServiceTests.cs b/test/Core.Test/Platform/PushRegistration/RelayPushRegistrationServiceTests.cs similarity index 95% rename from test/Core.Test/Platform/Push/Services/RelayPushRegistrationServiceTests.cs rename to test/Core.Test/Platform/PushRegistration/RelayPushRegistrationServiceTests.cs index 062b4a96a8..1abadacd24 100644 --- a/test/Core.Test/Platform/Push/Services/RelayPushRegistrationServiceTests.cs +++ b/test/Core.Test/Platform/PushRegistration/RelayPushRegistrationServiceTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Settings; using Microsoft.Extensions.Logging; using NSubstitute; diff --git a/test/Core.Test/Services/DeviceServiceTests.cs b/test/Core.Test/Services/DeviceServiceTests.cs index b454a0c04b..f34a906404 100644 --- a/test/Core.Test/Services/DeviceServiceTests.cs +++ b/test/Core.Test/Services/DeviceServiceTests.cs @@ -4,8 +4,8 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 1d39d63ef7..92e80b073d 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Services; using Bit.Core.Platform.Push; -using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Infrastructure.EntityFramework.Repositories; From 0074860cad0a4f42a12ca79ec2c49bfb947cf138 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:42:46 -0500 Subject: [PATCH 07/85] chore: remove account deprovisioning feature flag definition, refs PM-14614 (#6250) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2fbf7caffd..8944eec03b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -106,7 +106,6 @@ public static class AuthenticationSchemes public static class FeatureFlagKeys { /* Admin Console Team */ - public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string PolicyRequirements = "pm-14439-policy-requirements"; From 8ceb6f56217de10095172d899719edbdee179da7 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Wed, 27 Aug 2025 11:01:22 -0400 Subject: [PATCH 08/85] [PM-24278] Create Remove Individual Vault validator (#6139) --- .../ConfirmOrganizationUserCommand.cs | 2 +- ...anizationDataOwnershipPolicyRequirement.cs | 40 ++- .../PolicyServiceCollectionExtensions.cs | 2 + ...rganizationDataOwnershipPolicyValidator.cs | 68 +++++ .../OrganizationPolicyValidator.cs | 49 ++++ .../Repositories/ICollectionRepository.cs | 2 +- .../Repositories/CollectionRepository.cs | 2 +- .../Repositories/CollectionRepository.cs | 2 +- .../OrganizationPolicyDetailsCustomization.cs | 35 +++ .../AutoFixture/PolicyDetailsFixtures.cs | 2 + .../ConfirmOrganizationUserCommandTests.cs | 6 +- ...aOwnershipPolicyRequirementFactoryTests.cs | 86 ++++++- ...zationDataOwnershipPolicyValidatorTests.cs | 239 ++++++++++++++++++ .../OrganizationPolicyValidatorTests.cs | 188 ++++++++++++++ .../ImportCiphersAsyncCommandTests.cs | 3 +- .../Vault/Services/CipherServiceTests.cs | 3 +- ...ts.cs => UpsertDefaultCollectionsTests.cs} | 22 +- 17 files changed, 709 insertions(+), 42 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs create mode 100644 test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs rename test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/{CreateDefaultCollectionsTests.cs => UpsertDefaultCollectionsTests.cs} (91%) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index cbedb6355d..83ec244c47 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -278,6 +278,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index 7ccb3f7807..cb72a51850 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -24,19 +25,19 @@ public enum OrganizationDataOwnershipState /// public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement { - private readonly IEnumerable _organizationIdsWithPolicyEnabled; + private readonly IEnumerable _policyDetails; /// /// The organization data ownership state for the user. /// - /// - /// The collection of Organization IDs that have the Organization Data Ownership policy enabled. + /// + /// An enumerable collection of PolicyDetails for the organizations. /// public OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState organizationDataOwnershipState, - IEnumerable organizationIdsWithPolicyEnabled) + IEnumerable policyDetails) { - _organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? []; + _policyDetails = policyDetails; State = organizationDataOwnershipState; } @@ -46,14 +47,34 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement public OrganizationDataOwnershipState State { get; } /// - /// Returns true if the Organization Data Ownership policy is enforced in that organization. + /// Gets a default collection request for enforcing the Organization Data Ownership policy. + /// Only confirmed users are applicable. + /// This indicates whether the user should have a default collection created for them when the policy is enabled, + /// and if so, the relevant OrganizationUserId to create the collection for. /// - public bool RequiresDefaultCollection(Guid organizationId) + /// The organization ID to create the request for. + /// A DefaultCollectionRequest containing the OrganizationUserId and a flag indicating whether to create a default collection. + public DefaultCollectionRequest GetDefaultCollectionRequestOnPolicyEnable(Guid organizationId) { - return _organizationIdsWithPolicyEnabled.Contains(organizationId); + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + if (policyDetail != null && policyDetail.HasStatus([OrganizationUserStatusType.Confirmed])) + { + return new DefaultCollectionRequest(policyDetail.OrganizationUserId, true); + } + + var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false); + return noCollectionNeeded; } } +public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) +{ + public readonly bool ShouldCreateDefaultCollection = ShouldCreateDefaultCollection; + public readonly Guid OrganizationUserId = OrganizationUserId; +} + public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory { public override PolicyType PolicyType => PolicyType.OrganizationDataOwnership; @@ -63,10 +84,9 @@ public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequi var organizationDataOwnershipState = policyDetails.Any() ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled; - var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet(); return new OrganizationDataOwnershipPolicyRequirement( organizationDataOwnershipState, - organizationIdsWithPolicyEnabled); + policyDetails); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e31e9d44c9..12dd3f973d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -27,6 +27,8 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // This validator will be hooked up in https://bitwarden.atlassian.net/browse/PM-24279. + // services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs new file mode 100644 index 0000000000..2471bda647 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -0,0 +1,68 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class OrganizationDataOwnershipPolicyValidator( + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + IEnumerable> factories, + IFeatureService featureService, + ILogger logger) + : OrganizationPolicyValidator(policyRepository, factories) +{ + public override PolicyType Type => PolicyType.OrganizationDataOwnership; + + public override IEnumerable RequiredPolicies => []; + + public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + + public override async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + if (currentPolicy?.Enabled != true && policyUpdate.Enabled) + { + await UpsertDefaultCollectionsForUsersAsync(policyUpdate); + } + } + + private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate) + { + var requirements = await GetUserPolicyRequirementsByOrganizationIdAsync(policyUpdate.OrganizationId, policyUpdate.Type); + + var userOrgIds = requirements + .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) + .Where(request => request.ShouldCreateDefaultCollection) + .Select(request => request.OrganizationUserId); + + if (!userOrgIds.Any()) + { + logger.LogError("No UserOrganizationIds found for {OrganizationId}", policyUpdate.OrganizationId); + return; + } + + await collectionRepository.UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + userOrgIds, + GetDefaultUserCollectionName()); + } + + private static string GetDefaultUserCollectionName() + { + // TODO: https://bitwarden.atlassian.net/browse/PM-24279 + const string temporaryPlaceHolderValue = "Default"; + return temporaryPlaceHolderValue; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs new file mode 100644 index 0000000000..33667b829c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs @@ -0,0 +1,49 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) : IPolicyValidator +{ + public abstract PolicyType Type { get; } + + public abstract IEnumerable RequiredPolicies { get; } + + protected async Task> GetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement + { + var factory = factories.OfType>().SingleOrDefault(); + if (factory is null) + { + throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); + } + + var policyDetails = await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); + var policyDetailGroups = policyDetails.GroupBy(policyDetail => policyDetail.UserId); + var requirements = new List(); + + foreach (var policyDetailGroup in policyDetailGroups) + { + var filteredPolicies = policyDetailGroup + .Where(factory.Enforce) + // Prevent deferred execution from causing inconsistent tests. + .ToList(); + + requirements.Add(factory.Create(filteredPolicies)); + } + + return requirements; + } + + public abstract Task OnSaveSideEffectsAsync( + PolicyUpdate policyUpdate, + Policy? currentPolicy + ); + + public abstract Task ValidateAsync( + PolicyUpdate policyUpdate, + Policy? currentPolicy + ); +} diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index 70bda3eb13..ca3e52751c 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -63,5 +63,5 @@ public interface ICollectionRepository : IRepository Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); - Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 77fbdff3ae..ad00ac7086 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -326,7 +326,7 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) { if (!affectedOrgUserIds.Any()) { diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 569e541163..021b5bcf16 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -793,7 +793,7 @@ public class CollectionRepository : Repository affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) { if (!affectedOrgUserIds.Any()) { diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs new file mode 100644 index 0000000000..cf16563b8c --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AdminConsole.AutoFixture; + +internal class OrganizationPolicyDetailsCustomization( + PolicyType policyType, + OrganizationUserType userType, + bool isProvider, + OrganizationUserStatusType userStatus) : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.PolicyType, policyType) + .With(o => o.OrganizationUserType, userType) + .With(o => o.IsProvider, isProvider) + .With(o => o.OrganizationUserStatus, userStatus) + .Without(o => o.PolicyData)); // avoid autogenerating invalid json data + } +} + +public class OrganizationPolicyDetailsAttribute( + PolicyType policyType, + OrganizationUserType userType = OrganizationUserType.User, + bool isProvider = false, + OrganizationUserStatusType userStatus = OrganizationUserStatusType.Confirmed) : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + => new OrganizationPolicyDetailsCustomization(policyType, userType, isProvider, userStatus); +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs index 87ea390cb6..39d7732198 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs @@ -33,3 +33,5 @@ public class PolicyDetailsAttribute( public override ICustomization GetCustomization(ParameterInfo parameter) => new PolicyDetailsCustomization(policyType, userType, isProvider, userStatus); } + + diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index a8219ebcaa..31938fe4fc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -479,7 +479,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .Received(1) - .CreateDefaultCollectionsAsync( + .UpsertDefaultCollectionsAsync( organization.Id, Arg.Is>(ids => ids.Contains(orgUser.Id)), collectionName); @@ -505,7 +505,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -531,6 +531,6 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs index 95037efb97..ab4788c808 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Enums; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -30,24 +31,85 @@ public class OrganizationDataOwnershipPolicyRequirementFactoryTests } [Theory, BitAutoData] - public void RequiresDefaultCollection_WithNoPolicies_ReturnsFalse( - Guid organizationId, - SutProvider sutProvider) + public void PolicyType_ReturnsOrganizationDataOwnership(SutProvider sutProvider) { - var actual = sutProvider.Sut.Create([]); - - Assert.False(actual.RequiresDefaultCollection(organizationId)); + Assert.Equal(PolicyType.OrganizationDataOwnership, sutProvider.Sut.PolicyType); } [Theory, BitAutoData] - public void RequiresDefaultCollection_WithOrganizationDataOwnershipPolicies_ReturnsCorrectResult( - [PolicyDetails(PolicyType.OrganizationDataOwnership)] PolicyDetails[] policies, - Guid nonPolicyOrganizationId, + public void GetDefaultCollectionRequestOnPolicyEnable_WithConfirmedUser_ReturnsTrue( + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create(policies); + var expectedOrganizationUserId = policies[0].OrganizationUserId; + var organizationId = policies[0].OrganizationId; + + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(expectedOrganizationUserId, result.OrganizationUserId); + Assert.True(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithAcceptedUser_ReturnsFalse( + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Accepted)] PolicyDetails[] policies, SutProvider sutProvider) { - var actual = sutProvider.Sut.Create(policies); + // Arrange + var requirement = sutProvider.Sut.Create(policies); + var organizationId = policies[0].OrganizationId; - Assert.True(actual.RequiresDefaultCollection(policies[0].OrganizationId)); - Assert.False(actual.RequiresDefaultCollection(nonPolicyOrganizationId)); + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(Guid.Empty, result.OrganizationUserId); + Assert.False(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithNoPolicies_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create([]); + var organizationId = Guid.NewGuid(); + + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(Guid.Empty, result.OrganizationUserId); + Assert.False(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithMixedStatuses( + [PolicyDetails(PolicyType.OrganizationDataOwnership)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create(policies); + + var confirmedPolicy = policies[0]; + var acceptedPolicy = policies[1]; + + confirmedPolicy.OrganizationUserStatus = OrganizationUserStatusType.Confirmed; + acceptedPolicy.OrganizationUserStatus = OrganizationUserStatusType.Accepted; + + // Act + var confirmedResult = requirement.GetDefaultCollectionRequestOnPolicyEnable(confirmedPolicy.OrganizationId); + var acceptedResult = requirement.GetDefaultCollectionRequestOnPolicyEnable(acceptedPolicy.OrganizationId); + + // Assert + Assert.Equal(Guid.Empty, acceptedResult.OrganizationUserId); + Assert.False(acceptedResult.ShouldCreateDefaultCollection); + + Assert.Equal(confirmedPolicy.OrganizationUserId, confirmedResult.OrganizationUserId); + Assert.True(confirmedResult.ShouldCreateDefaultCollection); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs new file mode 100644 index 0000000000..2569bc6988 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -0,0 +1,239 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class OrganizationDataOwnershipPolicyValidatorTests +{ + private const string _defaultUserCollectionName = "Default"; + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_FeatureFlagDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(false); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_PolicyBeingDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_WhenNoUsersExist_ShouldLogError( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + OrganizationDataOwnershipPolicyRequirementFactory factory) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + var policyRepository = ArrangePolicyRepositoryWithOutUsers(); + var collectionRepository = Substitute.For(); + var logger = Substitute.For>(); + + var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + + // Act + await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await collectionRepository + .DidNotReceive() + .UpsertDefaultCollectionsAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + + const string expectedErrorMessage = "No UserOrganizationIds found for"; + + logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains(expectedErrorMessage)), + Arg.Any(), + Arg.Any>()); + } + + public static IEnumerable ShouldUpsertDefaultCollectionsTestCases() + { + yield return WithExistingPolicy(); + + yield return WithNoExistingPolicy(); + yield break; + + object?[] WithExistingPolicy() + { + var organizationId = Guid.NewGuid(); + var policyUpdate = new PolicyUpdate + { + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + var currentPolicy = new Policy + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = false + }; + + return new object?[] + { + policyUpdate, + currentPolicy + }; + } + + object?[] WithNoExistingPolicy() + { + var policyUpdate = new PolicyUpdate + { + OrganizationId = new Guid(), + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + + const Policy currentPolicy = null; + + return new object?[] + { + policyUpdate, + currentPolicy + }; + } + } + [Theory, BitAutoData] + [BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))] + public async Task OnSaveSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy? currentPolicy, + [OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable orgPolicyDetails, + OrganizationDataOwnershipPolicyRequirementFactory factory) + { + // Arrange + foreach (var policyDetail in orgPolicyDetails) + { + policyDetail.OrganizationId = policyUpdate.OrganizationId; + } + + var policyRepository = ArrangePolicyRepository(orgPolicyDetails); + var collectionRepository = Substitute.For(); + var logger = Substitute.For>(); + + var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + + // Act + await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await collectionRepository + .Received(1) + .UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + Arg.Is>(ids => ids.Count() == 3), + _defaultUserCollectionName); + } + + private static IPolicyRepository ArrangePolicyRepositoryWithOutUsers() + { + return ArrangePolicyRepository([]); + } + + private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) + { + var policyRepository = Substitute.For(); + + policyRepository + .GetPolicyDetailsByOrganizationIdAsync(Arg.Any(), PolicyType.OrganizationDataOwnership) + .Returns(policyDetails); + return policyRepository; + } + + private static OrganizationDataOwnershipPolicyValidator ArrangeSut( + OrganizationDataOwnershipPolicyRequirementFactory factory, + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + ILogger logger = null!) + { + logger ??= Substitute.For>(); + + var featureService = Substitute.For(); + featureService + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService, logger); + return sut; + } + +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs new file mode 100644 index 0000000000..aec1230423 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs @@ -0,0 +1,188 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class OrganizationPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithNoFactory_ThrowsNotImplementedException( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), []); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication)); + + Assert.Contains("No Requirement Factory found for", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithMultipleUsers_GroupsByUserId( + Guid organizationId, + Guid userId1, + Guid userId2, + SutProvider sutProvider) + { + // Arrange + var policyDetails = new List + { + new() { UserId = userId1, OrganizationId = organizationId }, + new() { UserId = userId1, OrganizationId = Guid.NewGuid() }, + new() { UserId = userId2, OrganizationId = organizationId } + }; + + var factory = Substitute.For>(); + factory.Create(Arg.Any>()).Returns(new TestPolicyRequirement()); + factory.Enforce(Arg.Any()).Returns(true); + + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policyDetails); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Equal(2, result.Count()); + + factory.Received(2).Create(Arg.Any>()); + factory.Received(1).Create(Arg.Is>( + results => results.Count() == 1 && results.First().UserId == userId2)); + factory.Received(1).Create(Arg.Is>( + results => results.Count() == 2 && results.First().UserId == userId1)); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_ShouldEnforceFilters( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var adminUser = new OrganizationPolicyDetails() + { + UserId = userId, + OrganizationId = organizationId, + OrganizationUserType = OrganizationUserType.Admin + }; + + var user = new OrganizationPolicyDetails() + { + UserId = userId, + OrganizationId = organizationId, + OrganizationUserType = OrganizationUserType.User + }; + + var policyDetails = new List + { + adminUser, + user + }; + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policyDetails); + + var factory = Substitute.For>(); + factory.Create(Arg.Any>()).Returns(new TestPolicyRequirement()); + factory.Enforce(Arg.Is(p => p.OrganizationUserType == OrganizationUserType.Admin)) + .Returns(true); + factory.Enforce(Arg.Is(p => p.OrganizationUserType == OrganizationUserType.User)) + .Returns(false); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Single(result); + + factory.Received(1).Create(Arg.Is>(policies => + policies.Count() == 1 && policies.First().OrganizationUserType == OrganizationUserType.Admin)); + + factory.Received(1).Enforce(Arg.Is(p => ReferenceEquals(p, adminUser))); + factory.Received(1).Enforce(Arg.Is(p => ReferenceEquals(p, user))); + factory.Received(2).Enforce(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithEmptyPolicyDetails_ReturnsEmptyCollection( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var factory = Substitute.For>(); + + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(new List()); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Empty(result); + factory.DidNotReceive().Create(Arg.Any>()); + } +} + +public class TestOrganizationPolicyValidator : OrganizationPolicyValidator +{ + public TestOrganizationPolicyValidator( + IPolicyRepository policyRepository, + IEnumerable>? factories = null) + : base(policyRepository, factories ?? []) + { + } + + public override PolicyType Type => PolicyType.TwoFactorAuthentication; + + public override IEnumerable RequiredPolicies => []; + + public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return Task.FromResult(""); + } + + public override Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return Task.CompletedTask; + } + + public async Task> TestGetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) + where T : IPolicyRequirement + { + return await GetUserPolicyRequirementsByOrganizationIdAsync(organizationId, policyType); + } + +} + +public class TestPolicyRequirement : IPolicyRequirement +{ +} diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index 1b50779c57..0cb0deaf52 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; @@ -120,7 +121,7 @@ public class ImportCiphersAsyncCommandTests .GetAsync(userId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, - [Guid.NewGuid()])); + [new PolicyDetails()])); var folderRelationships = new List>(); diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 0cee6530c2..55db5a9143 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; @@ -173,7 +174,7 @@ public class CipherServiceTests .GetAsync(savingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, - [Guid.NewGuid()])); + [new PolicyDetails()])); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null)); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs similarity index 91% rename from test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs rename to test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs index d85cc1e813..64dffa473f 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs @@ -6,10 +6,10 @@ using Xunit; namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; -public class CreateDefaultCollectionsTests +public class UpsertDefaultCollectionsTests { - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -28,7 +28,7 @@ public class CreateDefaultCollectionsTests var defaultCollectionName = $"default-name-{organization.Id}"; // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -36,8 +36,8 @@ public class CreateDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -66,7 +66,7 @@ public class CreateDefaultCollectionsTests var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id); // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id); @@ -74,8 +74,8 @@ public class CreateDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers); } - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -96,7 +96,7 @@ public class CreateDefaultCollectionsTests await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers); // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -108,7 +108,7 @@ public class CreateDefaultCollectionsTests Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName, OrganizationUser[] resultOrganizationUsers) { - await collectionRepository.CreateDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId); } From d24cbf25c7f9bac159528e93c6160bfc3ecc91a2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:12:23 +0000 Subject: [PATCH 09/85] [deps] Tools: Update aws-sdk-net monorepo (#6254) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 0dbb8e3023..25e74d8aee 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 5dfed7623b626b5c3da9fd90d0668f21f3f5b474 Mon Sep 17 00:00:00 2001 From: Maksym Sorokin Date: Thu, 28 Aug 2025 15:36:02 +0200 Subject: [PATCH 10/85] Fixed Nginx entrypoint to cp with preserve owner (#6249) If user cleanly follow install instructions Setup app will create nginx `default.conf` (and other files) with `644` permission owned by `bitwarden:bitwarden`. During Nginx entrypoint script it copies generated `default.conf` to `/etc/nginx/conf.d/` but without `-p` flag new file permissions would be `root:root 644`. Then during startup Nginx will start as `bitwarden` user, which will not cause any issues by itself as `default.conf` is still readable by the world. The issue is that for some reason some users have their Nginx config file (or sometimes even entire `bwdata` recursively) have `600` or `700` permissions. In this case Nginx will fail to start due to `default.conf` not readable by `bitwarden` user. I assume that root cause is that some users mistakenly run `sudo chmod -R 700 /opt/bitwarden` from Linux installation guide after they have run `./bitwarden.sh install`. Or maybe some older version of Setup app where creating `default.conf` with `600` permissions and users are using very legacy installations. Whatever may be the case I do not see any harm with copying with `-p` it even looks to me that this was the intended behavior. This will both fix the issue for mentioned users and preserve permission structure aligned with other files. --- util/Nginx/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/Nginx/entrypoint.sh b/util/Nginx/entrypoint.sh index 0d4fa73802..627430ee79 100644 --- a/util/Nginx/entrypoint.sh +++ b/util/Nginx/entrypoint.sh @@ -30,7 +30,7 @@ mkhomedir_helper $USERNAME # The rest... chown -R $USERNAME:$GROUPNAME /etc/bitwarden -cp /etc/bitwarden/nginx/*.conf /etc/nginx/conf.d/ +cp -p /etc/bitwarden/nginx/*.conf /etc/nginx/conf.d/ mkdir -p /etc/letsencrypt chown -R $USERNAME:$GROUPNAME /etc/letsencrypt mkdir -p /etc/ssl From 5a96f6dccec0a0638a17369712e59124ccc141af Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Fri, 29 Aug 2025 16:14:00 -0400 Subject: [PATCH 11/85] chore(feature-flags): Remove storage-reseed feature flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8944eec03b..dec605f4bc 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -188,7 +188,6 @@ public static class FeatureFlagKeys /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; - public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string WebPush = "web-push"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; From 1c60b805bf80c190332f954e0922d7544eb77284 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Sat, 30 Aug 2025 11:45:32 -0400 Subject: [PATCH 12/85] chore(feature-flag): [PM-19665] Remove web-push feature flag * Remove storage-reseed feature flag * Remove web-push feature flag. * Removed check for web push enabled. * Linting --- src/Api/Models/Response/ConfigResponseModel.cs | 8 +++----- src/Core/Constants.cs | 1 - .../Factories/WebApplicationFactoryBase.cs | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index d748254206..20bc3f9e10 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Services; @@ -46,8 +45,7 @@ public class ConfigResponseModel : ResponseModel Sso = globalSettings.BaseServiceUri.Sso }; FeatureStates = featureService.GetAll(); - var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false; - Push = PushSettings.Build(webPushEnabled, globalSettings); + Push = PushSettings.Build(globalSettings); Settings = new ServerSettingsResponseModel { DisableUserRegistration = globalSettings.DisableUserRegistration @@ -76,9 +74,9 @@ public class PushSettings public PushTechnologyType PushTechnology { get; private init; } public string VapidPublicKey { get; private init; } - public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings) + public static PushSettings Build(IGlobalSettings globalSettings) { - var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null; + var vapidPublicKey = globalSettings.WebPush.VapidPublicKey; var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR; return new() { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index dec605f4bc..56c6cd5476 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -188,7 +188,6 @@ public static class FeatureFlagKeys /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; - public const string WebPush = "web-push"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 92e80b073d..d05f940c09 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -153,7 +153,6 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory // Web push notifications { "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" }, - { "globalSettings:launchDarkly:flagValues:web-push", "true" }, }; // Some database drivers modify the connection string From 9a6cdcd5e2b02bb1f5753dd9a1c2477b880c6e39 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:14:45 -0400 Subject: [PATCH 13/85] chore(feature-flag): [PM-18516] Remove pm-9112-device-approval-persistence flag * Remove persistence feature flags * Added back 2FA value. --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 56c6cd5476..f78cc28032 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -116,7 +116,6 @@ public static class FeatureFlagKeys public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; /* Auth Team */ - public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; From 697fa6fdbc220173319fdee091c2b2fbf8296d99 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:39:49 -0400 Subject: [PATCH 14/85] chore(feature-flag): [PM-25336] Remove unauth-ui-refresh flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f78cc28032..39bd3fea5d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -118,7 +118,6 @@ public static class FeatureFlagKeys /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; - public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; From 101e29b3541248982ad18f60c56bd790dfaca949 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 2 Sep 2025 10:52:23 -0400 Subject: [PATCH 15/85] [PM-15354] fix EF implementation to match dapper (missing null check) (#6261) * fix EF implementation to match dapper (missing null check) * cleanup --- .../Repositories/OrganizationDomainRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs index e7bee0cdfd..0ddf80130e 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs @@ -152,7 +152,7 @@ public class OrganizationDomainRepository : Repository x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod)) + .Where(x => x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod) && x.VerifiedDate == null) .ToListAsync(); dbContext.OrganizationDomains.RemoveRange(expiredDomains); return await dbContext.SaveChangesAsync() > 0; From cb1db262cacc7a5c95e5ed50ba94e4b9c1ad3ae6 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:18:36 -0400 Subject: [PATCH 16/85] chore(feature-flag): [PM-18179] Remove pm-17128-recovery-code-login feature flag * Rmoved feature flag and obsolete endpoint * Removed obsolete method. --- .../Auth/Controllers/TwoFactorController.cs | 15 --------- src/Core/Constants.cs | 1 - src/Core/Services/IUserService.cs | 3 -- .../Services/Implementations/UserService.cs | 33 ------------------- 4 files changed, 52 deletions(-) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 96b64f16fc..4155489daa 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -409,21 +409,6 @@ public class TwoFactorController : Controller return response; } - /// - /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. - /// - [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] - [HttpPost("recover")] - [AllowAnonymous] - public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) - { - if (!await _userService.RecoverTwoFactorAsync(model.Email, model.MasterPasswordHash, model.RecoveryCode)) - { - await Task.Delay(2000); - throw new BadRequestException(string.Empty, "Invalid information. Try again."); - } - } - [Obsolete("Leaving this for backwards compatibility on clients")] [HttpGet("get-device-verification-settings")] public Task GetDeviceVerificationSettings() diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 39bd3fea5d..352daee862 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -121,7 +121,6 @@ public static class FeatureFlagKeys public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; - public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 8457a9c128..ef602be93a 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -90,9 +90,6 @@ public interface IUserService void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true); - [Obsolete("To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.")] - Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); - /// /// This method is used by the TwoFactorAuthenticationValidator to recover two /// factor for a user. This allows users to be logged in after a successful recovery diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 0da565c4ba..16e298d177 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -865,39 +865,6 @@ public class UserService : UserManager, IUserService } } - /// - /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. - /// - [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] - public async Task RecoverTwoFactorAsync(string email, string secret, string recoveryCode) - { - var user = await _userRepository.GetByEmailAsync(email); - if (user == null) - { - // No user exists. Do we want to send an email telling them this in the future? - return false; - } - - if (!await VerifySecretAsync(user, secret)) - { - return false; - } - - if (!CoreHelpers.FixedTimeEquals(user.TwoFactorRecoveryCode, recoveryCode)) - { - return false; - } - - user.TwoFactorProviders = null; - user.TwoFactorRecoveryCode = CoreHelpers.SecureRandomString(32, upper: false, special: false); - await SaveUserAsync(user); - await _mailService.SendRecoverTwoFactorEmail(user.Email, DateTime.UtcNow, _currentContext.IpAddress); - await _eventService.LogUserEventAsync(user.Id, EventType.User_Recovered2fa); - await CheckPoliciesOnTwoFactorRemovalAsync(user); - - return true; - } - public async Task RecoverTwoFactorAsync(User user, string recoveryCode) { if (!CoreHelpers.FixedTimeEquals( From a180317509b4b200185d2ffbbfdccf9f49560a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 2 Sep 2025 18:30:53 +0200 Subject: [PATCH 17/85] [PM-25182] Improve swagger OperationIDs: Part 1 (#6229) * Improve swagger OperationIDs: Part 1 * Fix tests and fmt * Improve docs and add more tests * Fmt * Improve Swagger OperationIDs for Auth * Fix review feedback * Use generic getcustomattributes * Format * replace swaggerexclude by split+obsolete * Format * Some remaining excludes --- dev/generate_openapi_files.ps1 | 9 ++ .../Auth/Controllers/AccountsController.cs | 32 ++++++- .../Controllers/AuthRequestsController.cs | 2 +- .../Controllers/EmergencyAccessController.cs | 18 +++- .../Auth/Controllers/TwoFactorController.cs | 67 +++++++++++++-- src/Api/Controllers/CollectionsController.cs | 26 +++++- src/Api/Controllers/DevicesController.cs | 55 ++++++++++-- src/Api/Controllers/InfoController.cs | 8 +- src/Api/Controllers/SettingsController.cs | 8 +- .../Utilities/ServiceCollectionExtensions.cs | 10 +-- src/Identity/Controllers/InfoController.cs | 8 +- src/Identity/Startup.cs | 10 +-- .../Swagger/ActionNameOperationFilter.cs | 25 ++++++ ...heckDuplicateOperationIdsDocumentFilter.cs | 80 +++++++++++++++++ src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs | 33 +++++++ .../AuthRequestsControllerTests.cs | 2 +- .../Controllers/DevicesControllerTests.cs | 4 +- .../Controllers/CollectionsControllerTests.cs | 4 +- .../ActionNameOperationFilterTest.cs | 67 +++++++++++++++ ...DuplicateOperationIdsDocumentFilterTest.cs | 84 ++++++++++++++++++ test/SharedWeb.Test/SharedWeb.Test.csproj | 1 + test/SharedWeb.Test/SwaggerDocUtil.cs | 85 +++++++++++++++++++ 22 files changed, 583 insertions(+), 55 deletions(-) create mode 100644 src/SharedWeb/Swagger/ActionNameOperationFilter.cs create mode 100644 src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs create mode 100644 src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs create mode 100644 test/SharedWeb.Test/ActionNameOperationFilterTest.cs create mode 100644 test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs create mode 100644 test/SharedWeb.Test/SwaggerDocUtil.cs diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 index 02470a0b1d..9eca7dc734 100644 --- a/dev/generate_openapi_files.ps1 +++ b/dev/generate_openapi_files.ps1 @@ -11,9 +11,18 @@ dotnet tool restore Set-Location "./src/Identity" dotnet build dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} # Api internal & public Set-Location "../../src/Api" dotnet build dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index f197f1270b..0bed7c29c4 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -344,7 +344,6 @@ public class AccountsController : Controller } [HttpPut("profile")] - [HttpPost("profile")] public async Task PutProfile([FromBody] UpdateProfileRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -363,8 +362,14 @@ public class AccountsController : Controller return response; } + [HttpPost("profile")] + [Obsolete("This endpoint is deprecated. Use PUT /profile instead.")] + public async Task PostProfile([FromBody] UpdateProfileRequestModel model) + { + return await PutProfile(model); + } + [HttpPut("avatar")] - [HttpPost("avatar")] public async Task PutAvatar([FromBody] UpdateAvatarRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -382,6 +387,13 @@ public class AccountsController : Controller return response; } + [HttpPost("avatar")] + [Obsolete("This endpoint is deprecated. Use PUT /avatar instead.")] + public async Task PostAvatar([FromBody] UpdateAvatarRequestModel model) + { + return await PutAvatar(model); + } + [HttpGet("revision-date")] public async Task GetAccountRevisionDate() { @@ -430,7 +442,6 @@ public class AccountsController : Controller } [HttpDelete] - [HttpPost("delete")] public async Task Delete([FromBody] SecretVerificationRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -467,6 +478,13 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE / instead.")] + public async Task PostDelete([FromBody] SecretVerificationRequestModel model) + { + await Delete(model); + } + [AllowAnonymous] [HttpPost("delete-recover")] public async Task PostDeleteRecover([FromBody] DeleteRecoverRequestModel model) @@ -638,7 +656,6 @@ public class AccountsController : Controller await _twoFactorEmailService.SendNewDeviceVerificationEmailAsync(user); } - [HttpPost("verify-devices")] [HttpPut("verify-devices")] public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) { @@ -654,6 +671,13 @@ public class AccountsController : Controller await _userService.SaveUserAsync(user); } + [HttpPost("verify-devices")] + [Obsolete("This endpoint is deprecated. Use PUT /verify-devices instead.")] + public async Task PostSetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) + { + await SetUserVerifyDevicesAsync(request); + } + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId); diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index 3f91bd6eea..c62b817905 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -31,7 +31,7 @@ public class AuthRequestsController( private readonly IAuthRequestService _authRequestService = authRequestService; [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId); diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 53b57fe685..b849dc3e07 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -79,7 +79,6 @@ public class EmergencyAccessController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); @@ -92,14 +91,27 @@ public class EmergencyAccessController : Controller await _emergencyAccessService.SaveAsync(model.ToEmergencyAccess(emergencyAccess), user); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model) + { + await Put(id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { var userId = _userService.GetProperUserId(User); await _emergencyAccessService.DeleteAsync(id, userId.Value); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } + [HttpPost("invite")] public async Task Invite([FromBody] EmergencyAccessInviteRequestModel model) { @@ -136,7 +148,7 @@ public class EmergencyAccessController : Controller } [HttpPost("{id}/approve")] - public async Task Accept(Guid id) + public async Task Approve(Guid id) { var user = await _userService.GetUserByPrincipalAsync(User); await _emergencyAccessService.ApproveAsync(id, user); diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 4155489daa..886ed2cd20 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -110,7 +110,6 @@ public class TwoFactorController : Controller } [HttpPut("authenticator")] - [HttpPost("authenticator")] public async Task PutAuthenticator( [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) { @@ -133,6 +132,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("authenticator")] + [Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")] + public async Task PostAuthenticator( + [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + { + return await PutAuthenticator(model); + } + [HttpDelete("authenticator")] public async Task DisableAuthenticator( [FromBody] TwoFactorAuthenticatorDisableRequestModel model) @@ -157,7 +164,6 @@ public class TwoFactorController : Controller } [HttpPut("yubikey")] - [HttpPost("yubikey")] public async Task PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) { var user = await CheckAsync(model, true); @@ -174,6 +180,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("yubikey")] + [Obsolete("This endpoint is deprecated. Use PUT /yubikey instead.")] + public async Task PostYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + { + return await PutYubiKey(model); + } + [HttpPost("get-duo")] public async Task GetDuo([FromBody] SecretVerificationRequestModel model) { @@ -183,7 +196,6 @@ public class TwoFactorController : Controller } [HttpPut("duo")] - [HttpPost("duo")] public async Task PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) { var user = await CheckAsync(model, true); @@ -199,6 +211,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("duo")] + [Obsolete("This endpoint is deprecated. Use PUT /duo instead.")] + public async Task PostDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + { + return await PutDuo(model); + } + [HttpPost("~/organizations/{id}/two-factor/get-duo")] public async Task GetOrganizationDuo(string id, [FromBody] SecretVerificationRequestModel model) @@ -217,7 +236,6 @@ public class TwoFactorController : Controller } [HttpPut("~/organizations/{id}/two-factor/duo")] - [HttpPost("~/organizations/{id}/two-factor/duo")] public async Task PutOrganizationDuo(string id, [FromBody] UpdateTwoFactorDuoRequestModel model) { @@ -243,6 +261,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("~/organizations/{id}/two-factor/duo")] + [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/duo instead.")] + public async Task PostOrganizationDuo(string id, + [FromBody] UpdateTwoFactorDuoRequestModel model) + { + return await PutOrganizationDuo(id, model); + } + [HttpPost("get-webauthn")] public async Task GetWebAuthn([FromBody] SecretVerificationRequestModel model) { @@ -261,7 +287,6 @@ public class TwoFactorController : Controller } [HttpPut("webauthn")] - [HttpPost("webauthn")] public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) { var user = await CheckAsync(model, false); @@ -277,6 +302,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("webauthn")] + [Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")] + public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + { + return await PutWebAuthn(model); + } + [HttpDelete("webauthn")] public async Task DeleteWebAuthn( [FromBody] TwoFactorWebAuthnDeleteRequestModel model) @@ -349,7 +381,6 @@ public class TwoFactorController : Controller } [HttpPut("email")] - [HttpPost("email")] public async Task PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model) { var user = await CheckAsync(model, false); @@ -367,8 +398,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("email")] + [Obsolete("This endpoint is deprecated. Use PUT /email instead.")] + public async Task PostEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + { + return await PutEmail(model); + } + [HttpPut("disable")] - [HttpPost("disable")] public async Task PutDisable([FromBody] TwoFactorProviderRequestModel model) { var user = await CheckAsync(model, false); @@ -377,8 +414,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("disable")] + [Obsolete("This endpoint is deprecated. Use PUT /disable instead.")] + public async Task PostDisable([FromBody] TwoFactorProviderRequestModel model) + { + return await PutDisable(model); + } + [HttpPut("~/organizations/{id}/two-factor/disable")] - [HttpPost("~/organizations/{id}/two-factor/disable")] public async Task PutOrganizationDisable(string id, [FromBody] TwoFactorProviderRequestModel model) { @@ -401,6 +444,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("~/organizations/{id}/two-factor/disable")] + [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/disable instead.")] + public async Task PostOrganizationDisable(string id, + [FromBody] TwoFactorProviderRequestModel model) + { + return await PutOrganizationDisable(id, model); + } + [HttpPost("get-recover")] public async Task GetRecover([FromBody] SecretVerificationRequestModel model) { diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 6d4e9c9fea..f037ab7034 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -102,7 +102,7 @@ public class CollectionsController : Controller } [HttpGet("")] - public async Task> Get(Guid orgId) + public async Task> GetAll(Guid orgId) { IEnumerable orgCollections; @@ -173,7 +173,6 @@ public class CollectionsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { var collection = await _collectionRepository.GetByIdAsync(id); @@ -198,6 +197,13 @@ public class CollectionsController : Controller return new CollectionAccessDetailsResponseModel(collectionWithPermissions); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) + { + return await Put(orgId, id, model); + } + [HttpPost("bulk-access")] public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model) { @@ -222,7 +228,6 @@ public class CollectionsController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid orgId, Guid id) { var collection = await _collectionRepository.GetByIdAsync(id); @@ -235,8 +240,14 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteAsync(collection); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDelete(Guid orgId, Guid id) + { + await Delete(orgId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids); @@ -248,4 +259,11 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteManyAsync(collections); } + + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE / instead.")] + public async Task PostDeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) + { + await DeleteMany(orgId, model); + } } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 07e8552268..1f2cda9cc4 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -75,7 +75,7 @@ public class DevicesController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var devicesWithPendingAuthData = await _deviceRepository.GetManyByUserIdWithDeviceAuth(_userService.GetProperUserId(User).Value); @@ -99,7 +99,6 @@ public class DevicesController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] DeviceRequestModel model) { var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); @@ -114,8 +113,14 @@ public class DevicesController : Controller return response; } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(string id, [FromBody] DeviceRequestModel model) + { + return await Put(id, model); + } + [HttpPut("{identifier}/keys")] - [HttpPost("{identifier}/keys")] public async Task PutKeys(string identifier, [FromBody] DeviceKeysRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -130,6 +135,13 @@ public class DevicesController : Controller return response; } + [HttpPost("{identifier}/keys")] + [Obsolete("This endpoint is deprecated. Use PUT /{identifier}/keys instead.")] + public async Task PostKeys(string identifier, [FromBody] DeviceKeysRequestModel model) + { + return await PutKeys(identifier, model); + } + [HttpPost("{identifier}/retrieve-keys")] [Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")] public async Task GetDeviceKeys(string identifier) @@ -187,7 +199,6 @@ public class DevicesController : Controller } [HttpPut("identifier/{identifier}/token")] - [HttpPost("identifier/{identifier}/token")] public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -199,8 +210,14 @@ public class DevicesController : Controller await _deviceService.SaveAsync(model.ToDevice(device)); } + [HttpPost("identifier/{identifier}/token")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/token instead.")] + public async Task PostToken(string identifier, [FromBody] DeviceTokenRequestModel model) + { + await PutToken(identifier, model); + } + [HttpPut("identifier/{identifier}/web-push-auth")] - [HttpPost("identifier/{identifier}/web-push-auth")] public async Task PutWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -216,9 +233,15 @@ public class DevicesController : Controller ); } + [HttpPost("identifier/{identifier}/web-push-auth")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/web-push-auth instead.")] + public async Task PostWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model) + { + await PutWebPushAuth(identifier, model); + } + [AllowAnonymous] [HttpPut("identifier/{identifier}/clear-token")] - [HttpPost("identifier/{identifier}/clear-token")] public async Task PutClearToken(string identifier) { var device = await _deviceRepository.GetByIdentifierAsync(identifier); @@ -230,8 +253,15 @@ public class DevicesController : Controller await _deviceService.ClearTokenAsync(device); } + [AllowAnonymous] + [HttpPost("identifier/{identifier}/clear-token")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/clear-token instead.")] + public async Task PostClearToken(string identifier) + { + await PutClearToken(identifier); + } + [HttpDelete("{id}")] - [HttpPost("{id}/deactivate")] public async Task Deactivate(string id) { var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); @@ -243,17 +273,24 @@ public class DevicesController : Controller await _deviceService.DeactivateAsync(device); } + [HttpPost("{id}/deactivate")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDeactivate(string id) + { + await Deactivate(id); + } + [AllowAnonymous] [HttpGet("knowndevice")] public async Task GetByIdentifierQuery( [Required][FromHeader(Name = "X-Request-Email")] string Email, [Required][FromHeader(Name = "X-Device-Identifier")] string DeviceIdentifier) - => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); + => await GetByEmailAndIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); [Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")] [AllowAnonymous] [HttpGet("knowndevice/{email}/{identifier}")] - public async Task GetByIdentifier(string email, string identifier) + public async Task GetByEmailAndIdentifier(string email, string identifier) { if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(identifier)) { diff --git a/src/Api/Controllers/InfoController.cs b/src/Api/Controllers/InfoController.cs index edfd18c79e..590a3006c0 100644 --- a/src/Api/Controllers/InfoController.cs +++ b/src/Api/Controllers/InfoController.cs @@ -6,12 +6,18 @@ namespace Bit.Api.Controllers; public class InfoController : Controller { [HttpGet("~/alive")] - [HttpGet("~/now")] public DateTime GetAlive() { return DateTime.UtcNow; } + [HttpGet("~/now")] + [Obsolete("This endpoint is deprecated. Use GET /alive instead.")] + public DateTime GetNow() + { + return GetAlive(); + } + [HttpGet("~/version")] public JsonResult GetVersion() { diff --git a/src/Api/Controllers/SettingsController.cs b/src/Api/Controllers/SettingsController.cs index 8489b137e8..e872eeeeac 100644 --- a/src/Api/Controllers/SettingsController.cs +++ b/src/Api/Controllers/SettingsController.cs @@ -32,7 +32,6 @@ public class SettingsController : Controller } [HttpPut("domains")] - [HttpPost("domains")] public async Task PutDomains([FromBody] UpdateDomainsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -46,4 +45,11 @@ public class SettingsController : Controller var response = new DomainsResponseModel(user); return response; } + + [HttpPost("domains")] + [Obsolete("This endpoint is deprecated. Use PUT /domains instead.")] + public async Task PostDomains([FromBody] UpdateDomainsRequestModel model) + { + return await PutDomains(model); + } } diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index aa2710c42a..0d8c3dec38 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -82,15 +82,7 @@ public static class ServiceCollectionExtensions config.DescribeAllParametersInCamelCase(); // config.UseReferencedDefinitionsForEnums(); - config.SchemaFilter(); - config.SchemaFilter(); - - // These two filters require debug symbols/git, so only add them in development mode - if (environment.IsDevelopment()) - { - config.DocumentFilter(); - config.OperationFilter(); - } + config.InitializeSwaggerFilters(environment); var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml"); config.IncludeXmlComments(apiFilePath, true); diff --git a/src/Identity/Controllers/InfoController.cs b/src/Identity/Controllers/InfoController.cs index 05cf3f2363..79dfd99c44 100644 --- a/src/Identity/Controllers/InfoController.cs +++ b/src/Identity/Controllers/InfoController.cs @@ -6,12 +6,18 @@ namespace Bit.Identity.Controllers; public class InfoController : Controller { [HttpGet("~/alive")] - [HttpGet("~/now")] public DateTime GetAlive() { return DateTime.UtcNow; } + [HttpGet("~/now")] + [Obsolete("This endpoint is deprecated. Use GET /alive instead.")] + public DateTime GetNow() + { + return GetAlive(); + } + [HttpGet("~/version")] public JsonResult GetVersion() { diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index ae628197e8..8da31d87d6 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -66,15 +66,7 @@ public class Startup services.AddSwaggerGen(config => { - config.SchemaFilter(); - config.SchemaFilter(); - - // These two filters require debug symbols/git, so only add them in development mode - if (Environment.IsDevelopment()) - { - config.DocumentFilter(); - config.OperationFilter(); - } + config.InitializeSwaggerFilters(Environment); config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" }); }); diff --git a/src/SharedWeb/Swagger/ActionNameOperationFilter.cs b/src/SharedWeb/Swagger/ActionNameOperationFilter.cs new file mode 100644 index 0000000000..b76e8864ba --- /dev/null +++ b/src/SharedWeb/Swagger/ActionNameOperationFilter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Adds the action name (function name) as an extension to each operation in the Swagger document. +/// This can be useful for the code generation process, to generate more meaningful names for operations. +/// Note that we add both the original action name and a snake_case version, as the codegen templates +/// cannot do case conversions. +/// +public class ActionNameOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (!context.ApiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)) return; + if (string.IsNullOrEmpty(action)) return; + + operation.Extensions.Add("x-action-name", new OpenApiString(action)); + // We can't do case changes in the codegen templates, so we also add the snake_case version of the action name + operation.Extensions.Add("x-action-name-snake-case", new OpenApiString(JsonNamingPolicy.SnakeCaseLower.ConvertName(action))); + } +} diff --git a/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs b/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs new file mode 100644 index 0000000000..3079a9171a --- /dev/null +++ b/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs @@ -0,0 +1,80 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Checks for duplicate operation IDs in the Swagger document, and throws an error if any are found. +/// Operation IDs must be unique across the entire Swagger document according to the OpenAPI specification, +/// but we use controller action names to generate them, which can lead to duplicates if a Controller function +/// has multiple HTTP methods or if a Controller has overloaded functions. +/// +public class CheckDuplicateOperationIdsDocumentFilter(bool printDuplicates = true) : IDocumentFilter +{ + public bool PrintDuplicates { get; } = printDuplicates; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var operationIdMap = new Dictionary>(); + + foreach (var (path, pathItem) in swaggerDoc.Paths) + { + foreach (var operation in pathItem.Operations) + { + if (!operationIdMap.TryGetValue(operation.Value.OperationId, out var list)) + { + list = []; + operationIdMap[operation.Value.OperationId] = list; + } + + list.Add((path, pathItem, operation.Key, operation.Value)); + + } + } + + // Find duplicates + var duplicates = operationIdMap.Where((kvp) => kvp.Value.Count > 1).ToList(); + if (duplicates.Count > 0) + { + if (PrintDuplicates) + { + Console.WriteLine($"\n######## Duplicate operationIds found in the schema ({duplicates.Count} found) ########\n"); + + Console.WriteLine("## Common causes of duplicate operation IDs:"); + Console.WriteLine("- Multiple HTTP methods (GET, POST, etc.) on the same controller function"); + Console.WriteLine(" Solution: Split the methods into separate functions, and if appropiate, mark the deprecated ones with [Obsolete]"); + Console.WriteLine(); + Console.WriteLine("- Overloaded controller functions with the same name"); + Console.WriteLine(" Solution: Rename the overloaded functions to have unique names, or combine them into a single function with optional parameters"); + Console.WriteLine(); + + Console.WriteLine("## The duplicate operation IDs are:"); + + foreach (var (operationId, duplicate) in duplicates) + { + Console.WriteLine($"- operationId: {operationId}"); + foreach (var (path, pathItem, method, operation) in duplicate) + { + Console.Write($" {method.ToString().ToUpper()} {path}"); + + + if (operation.Extensions.TryGetValue("x-source-file", out var sourceFile) && operation.Extensions.TryGetValue("x-source-line", out var sourceLine)) + { + var sourceFileString = ((Microsoft.OpenApi.Any.OpenApiString)sourceFile).Value; + var sourceLineString = ((Microsoft.OpenApi.Any.OpenApiInteger)sourceLine).Value; + + Console.WriteLine($" {sourceFileString}:{sourceLineString}"); + } + else + { + Console.WriteLine(); + } + } + Console.WriteLine("\n"); + } + } + + throw new InvalidOperationException($"Duplicate operation IDs found in Swagger schema"); + } + } +} diff --git a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs new file mode 100644 index 0000000000..60803705d6 --- /dev/null +++ b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +public static class SwaggerGenOptionsExt +{ + + public static void InitializeSwaggerFilters( + this SwaggerGenOptions config, IWebHostEnvironment environment) + { + config.SchemaFilter(); + config.SchemaFilter(); + + config.OperationFilter(); + + // Set the operation ID to the name of the controller followed by the name of the function. + // Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions + // are removed already, so we don't need to do that ourselves. + // TODO(Dani): This is disabled until we remove all the duplicate operation IDs. + // config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + // config.DocumentFilter(); + + // These two filters require debug symbols/git, so only add them in development mode + if (environment.IsDevelopment()) + { + config.DocumentFilter(); + config.OperationFilter(); + } + } +} diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs index 828911f6bd..1b8e7aba8e 100644 --- a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -43,7 +43,7 @@ public class AuthRequestsControllerTests .Returns([authRequest]); // Act - var result = await sutProvider.Sut.Get(); + var result = await sutProvider.Sut.GetAll(); // Assert Assert.NotNull(result); diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index 540d23f98b..bed483f83a 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -73,7 +73,7 @@ public class DevicesControllerTest _deviceRepositoryMock.GetManyByUserIdWithDeviceAuth(userId).Returns(devicesWithPendingAuthData); // Act - var result = await _sut.Get(); + var result = await _sut.GetAll(); // Assert Assert.NotNull(result); @@ -94,6 +94,6 @@ public class DevicesControllerTest _userServiceMock.GetProperUserId(Arg.Any()).Returns((Guid?)null); // Act & Assert - await Assert.ThrowsAsync(() => _sut.Get()); + await Assert.ThrowsAsync(() => _sut.GetAll()); } } diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index a3d34efb63..33b7e20327 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -177,7 +177,7 @@ public class CollectionsControllerTests .GetManySharedCollectionsByOrganizationIdAsync(organization.Id) .Returns(collections); - var response = await sutProvider.Sut.Get(organization.Id); + var response = await sutProvider.Sut.GetAll(organization.Id); await sutProvider.GetDependency().Received(1).GetManySharedCollectionsByOrganizationIdAsync(organization.Id); @@ -219,7 +219,7 @@ public class CollectionsControllerTests .GetManyByUserIdAsync(userId) .Returns(collections); - var result = await sutProvider.Sut.Get(organization.Id); + var result = await sutProvider.Sut.GetAll(organization.Id); await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdAsync(organization.Id); await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(userId); diff --git a/test/SharedWeb.Test/ActionNameOperationFilterTest.cs b/test/SharedWeb.Test/ActionNameOperationFilterTest.cs new file mode 100644 index 0000000000..c798adea8c --- /dev/null +++ b/test/SharedWeb.Test/ActionNameOperationFilterTest.cs @@ -0,0 +1,67 @@ +using Bit.SharedWeb.Swagger; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SharedWeb.Test; + +public class ActionNameOperationFilterTest +{ + [Fact] + public void WithValidActionNameAddsActionNameExtensions() + { + // Arrange + var operation = new OpenApiOperation(); + var actionDescriptor = new ActionDescriptor(); + actionDescriptor.RouteValues["action"] = "GetUsers"; + + var apiDescription = new ApiDescription + { + ActionDescriptor = actionDescriptor + }; + + var context = new OperationFilterContext(apiDescription, null, null, null); + var filter = new ActionNameOperationFilter(); + + // Act + filter.Apply(operation, context); + + // Assert + Assert.True(operation.Extensions.ContainsKey("x-action-name")); + Assert.True(operation.Extensions.ContainsKey("x-action-name-snake-case")); + + var actionNameExt = operation.Extensions["x-action-name"] as OpenApiString; + var actionNameSnakeCaseExt = operation.Extensions["x-action-name-snake-case"] as OpenApiString; + + Assert.NotNull(actionNameExt); + Assert.NotNull(actionNameSnakeCaseExt); + Assert.Equal("GetUsers", actionNameExt.Value); + Assert.Equal("get_users", actionNameSnakeCaseExt.Value); + } + + [Fact] + public void WithMissingActionRouteValueDoesNotAddExtensions() + { + // Arrange + var operation = new OpenApiOperation(); + var actionDescriptor = new ActionDescriptor(); + // Not setting the "action" route value at all + + var apiDescription = new ApiDescription + { + ActionDescriptor = actionDescriptor + }; + + var context = new OperationFilterContext(apiDescription, null, null, null); + var filter = new ActionNameOperationFilter(); + + // Act + filter.Apply(operation, context); + + // Assert + Assert.False(operation.Extensions.ContainsKey("x-action-name")); + Assert.False(operation.Extensions.ContainsKey("x-action-name-snake-case")); + } +} diff --git a/test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs b/test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs new file mode 100644 index 0000000000..7b7c5771d3 --- /dev/null +++ b/test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs @@ -0,0 +1,84 @@ +using Bit.SharedWeb.Swagger; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SharedWeb.Test; + +public class UniqueOperationIdsController : ControllerBase +{ + [HttpGet("unique-get")] + public void UniqueGetAction() { } + + [HttpPost("unique-post")] + public void UniquePostAction() { } +} + +public class OverloadedOperationIdsController : ControllerBase +{ + [HttpPut("another-duplicate")] + public void AnotherDuplicateAction() { } + + [HttpPatch("another-duplicate/{id}")] + public void AnotherDuplicateAction(int id) { } +} + +public class MultipleHttpMethodsController : ControllerBase +{ + [HttpGet("multi-method")] + [HttpPost("multi-method")] + [HttpPut("multi-method")] + public void MultiMethodAction() { } +} + +public class CheckDuplicateOperationIdsDocumentFilterTest +{ + [Fact] + public void UniqueOperationIdsDoNotThrowException() + { + // Arrange + var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(UniqueOperationIdsController)); + var filter = new CheckDuplicateOperationIdsDocumentFilter(); + filter.Apply(swaggerDoc, context); + // Act & Assert + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + Assert.Null(exception); + } + + [Fact] + public void DuplicateOperationIdsThrowInvalidOperationException() + { + // Arrange + var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(OverloadedOperationIdsController)); + var filter = new CheckDuplicateOperationIdsDocumentFilter(false); + + // Act & Assert + var exception = Assert.Throws(() => filter.Apply(swaggerDoc, context)); + Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message); + } + + [Fact] + public void MultipleHttpMethodsThrowInvalidOperationException() + { + // Arrange + var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(MultipleHttpMethodsController)); + var filter = new CheckDuplicateOperationIdsDocumentFilter(false); + + // Act & Assert + var exception = Assert.Throws(() => filter.Apply(swaggerDoc, context)); + Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message); + } + + [Fact] + public void EmptySwaggerDocDoesNotThrowException() + { + // Arrange + var swaggerDoc = new OpenApiDocument { Paths = [] }; + var context = new DocumentFilterContext([], null, null); + var filter = new CheckDuplicateOperationIdsDocumentFilter(false); + + // Act & Assert + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + Assert.Null(exception); + } +} diff --git a/test/SharedWeb.Test/SharedWeb.Test.csproj b/test/SharedWeb.Test/SharedWeb.Test.csproj index 8ae7a56a99..c631ac9227 100644 --- a/test/SharedWeb.Test/SharedWeb.Test.csproj +++ b/test/SharedWeb.Test/SharedWeb.Test.csproj @@ -9,6 +9,7 @@ all + diff --git a/test/SharedWeb.Test/SwaggerDocUtil.cs b/test/SharedWeb.Test/SwaggerDocUtil.cs new file mode 100644 index 0000000000..45a3033dec --- /dev/null +++ b/test/SharedWeb.Test/SwaggerDocUtil.cs @@ -0,0 +1,85 @@ +using System.Reflection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using NSubstitute; +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SharedWeb.Test; + +public class SwaggerDocUtil +{ + /// + /// Creates an OpenApiDocument and DocumentFilterContext from the specified controller type by setting up + /// a minimal service collection and using the SwaggerProvider to generate the document. + /// + public static (OpenApiDocument, DocumentFilterContext) CreateDocFromControllers(params Type[] controllerTypes) + { + if (controllerTypes.Length == 0) + { + throw new ArgumentException("At least one controller type must be provided", nameof(controllerTypes)); + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddControllers() + .ConfigureApplicationPartManager(manager => + { + // Clear existing parts and feature providers + manager.ApplicationParts.Clear(); + manager.FeatureProviders.Clear(); + + // Add a custom feature provider that only includes the specific controller types + manager.FeatureProviders.Add(new MultipleControllerFeatureProvider(controllerTypes)); + + // Add assembly parts for all unique assemblies containing the controllers + foreach (var assembly in controllerTypes.Select(t => t.Assembly).Distinct()) + { + manager.ApplicationParts.Add(new AssemblyPart(assembly)); + } + }); + services.AddSwaggerGen(config => + { + config.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" }); + config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + }); + var serviceProvider = services.BuildServiceProvider(); + + // Get API descriptions + var allApiDescriptions = serviceProvider.GetRequiredService() + .ApiDescriptionGroups.Items + .SelectMany(group => group.Items) + .ToList(); + + if (allApiDescriptions.Count == 0) + { + throw new InvalidOperationException("No API descriptions found for controller, ensure your controllers are defined correctly (public, not nested, inherit from ControllerBase, etc.)"); + } + + // Generate the swagger document and context + var document = serviceProvider.GetRequiredService().GetSwagger("v1"); + var schemaGenerator = serviceProvider.GetRequiredService(); + var context = new DocumentFilterContext(allApiDescriptions, schemaGenerator, new SchemaRepository()); + + return (document, context); + } +} + +public class MultipleControllerFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider +{ + private readonly HashSet _allowedControllerTypes = [.. controllerTypes]; + + protected override bool IsController(TypeInfo typeInfo) + { + return _allowedControllerTypes.Contains(typeInfo.AsType()) + && typeInfo.IsClass + && !typeInfo.IsAbstract + && typeof(ControllerBase).IsAssignableFrom(typeInfo); + } +} From 53e5ddb1a719aa4ca23004196b569c12c0ac6722 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Tue, 2 Sep 2025 12:44:28 -0400 Subject: [PATCH 18/85] fix(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Added feature flag. (#6270) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 352daee862..ce18706bd4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -187,6 +187,7 @@ public static class FeatureFlagKeys public const string PersistPopupView = "persist-popup-view"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; + public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; /* Tools Team */ public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; From a5bed5dcaab3aba3aba588531561add60927b273 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:02:02 -0500 Subject: [PATCH 19/85] [PM-25384] Add feature flag (#6271) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ce18706bd4..393ab15e4c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -163,6 +163,7 @@ public static class FeatureFlagKeys public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string PM17987_BlockType0 = "pm-17987-block-type-0"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; + public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; From d2d3e0f11b6950b172dd3e16ac1f47f310e9ec50 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:48:57 -0400 Subject: [PATCH 20/85] [PM-22678] Send email otp authentication method (#6255) feat(auth): email OTP validation, and generalize authentication interface - Generalized send authentication method interface - Made validate method async - Added email mail support for Handlebars - Modified email templates to match future implementation fix(auth): update constants, naming conventions, and error handling - Renamed constants for clarity - Updated claims naming convention - Fixed error message generation - Added customResponse for Rust consumption test(auth): add and fix tests for validators and email - Added tests for SendEmailOtpRequestValidator - Updated tests for SendAccessGrantValidator chore: apply dotnet formatting --- .../SendAccessClaimsPrincipalExtensions.cs | 6 +- src/Core/Identity/Claims.cs | 7 +- .../Auth/SendAccessEmailOtpEmail.html.hbs | 28 ++ .../Auth/SendAccessEmailOtpEmail.text.hbs | 9 + .../Mail/Auth/DefaultEmailOtpViewModel.cs | 12 + src/Core/Services/IMailService.cs | 1 + .../Implementations/HandlebarsMailService.cs | 21 ++ .../NoopImplementations/NoopMailService.cs | 5 + src/Identity/IdentityServer/ApiResources.cs | 2 +- .../ISendAuthenticationMethodValidator.cs | 15 + .../ISendPasswordRequestValidator.cs | 16 - .../SendAccess/SendAccessConstants.cs | 37 ++- .../SendAccess/SendAccessGrantValidator.cs | 38 +-- .../SendEmailOtpRequestValidator.cs | 134 ++++++++ .../SendPasswordRequestValidator.cs | 16 +- .../Utilities/ServiceCollectionExtensions.cs | 4 +- ...endAccessClaimsPrincipalExtensionsTests.cs | 8 +- .../Services/HandlebarsMailServiceTests.cs | 15 +- ...endAccessGrantValidatorIntegrationTests.cs | 4 +- ...EmailOtpReqestValidatorIntegrationTests.cs | 256 +++++++++++++++ .../SendAccessGrantValidatorTests.cs | 30 +- .../SendEmailOtpRequestValidatorTests.cs | 310 ++++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 297 +++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 32 +- 24 files changed, 1213 insertions(+), 90 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs create mode 100644 src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs delete mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs rename test/Identity.Test/IdentityServer/{ => SendAccess}/SendAccessGrantValidatorTests.cs (90%) create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs index 1feadaf081..7ae7355ba4 100644 --- a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs +++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs @@ -9,12 +9,12 @@ public static class SendAccessClaimsPrincipalExtensions { ArgumentNullException.ThrowIfNull(user); - var sendIdClaim = user.FindFirst(Claims.SendId) - ?? throw new InvalidOperationException("Send ID claim not found."); + var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId) + ?? throw new InvalidOperationException("send_id claim not found."); if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid)) { - throw new InvalidOperationException("Invalid Send ID claim value."); + throw new InvalidOperationException("Invalid send_id claim value."); } return sendGuid; diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs index ef3d5e450c..39a036f3f9 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Identity/Claims.cs @@ -39,6 +39,9 @@ public static class Claims public const string ManageResetPassword = "manageresetpassword"; public const string ManageScim = "managescim"; } - - public const string SendId = "send_id"; + public static class SendAccessClaims + { + public const string SendId = "send_id"; + public const string Email = "send_email"; + } } diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs new file mode 100644 index 0000000000..5bf1f24218 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs @@ -0,0 +1,28 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + +
+ Verify your email to access this Bitwarden Send. +
+
+ Your verification code is: {{Token}} +
+
+ This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. +
+
+
+ {{TheDate}} at {{TheTime}} {{TimeZone}} +
+{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs new file mode 100644 index 0000000000..f83008c30b --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs @@ -0,0 +1,9 @@ +{{#>BasicTextLayout}} +Verify your email to access this Bitwarden Send. + +Your verification code is: {{Token}} + +This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. + +Date : {{TheDate}} at {{TheTime}} {{TimeZone}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs new file mode 100644 index 0000000000..5faf550e60 --- /dev/null +++ b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Models.Mail.Auth; + +/// +/// Send email OTP view model +/// +public class DefaultEmailOtpViewModel : BaseMailModel +{ + public string? Token { get; set; } + public string? TheDate { get; set; } + public string? TheTime { get; set; } + public string? TimeZone { get; set; } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 32aaac84b7..a38328dc9d 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -30,6 +30,7 @@ public interface IMailService Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); + Task SendSendEmailOtpEmailAsync(string email, string token, string subject); Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index f06a37fa3b..394b5c5125 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Models.Mail.Auth; using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; @@ -199,6 +200,26 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject) + { + var message = CreateDefaultMessage(subject, email); + var requestDateTime = DateTime.UtcNow; + var model = new DefaultEmailOtpViewModel + { + Token = token, + TheDate = requestDateTime.ToLongDateString(), + TheTime = requestDateTime.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model); + message.MetaData.Add("SendGridBypassListManagement", true); + // TODO - PM-25380 change to string constant + message.Category = "SendEmailOtp"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { // Check if we've sent this email within the last hour diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 5847aaf929..bc73fb5398 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -93,6 +93,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendSendEmailOtpEmailAsync(string email, string token, string subject) + { + return Task.FromResult(0); + } + public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { return Task.FromResult(0); diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index eea53734cb..61f3dd10ba 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -29,7 +29,7 @@ public class ApiResources }), new(ApiScopes.ApiSendAccess, [ JwtClaimTypes.Subject, - Claims.SendId + Claims.SendAccessClaims.SendId ]), new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs new file mode 100644 index 0000000000..1ffb68ceca --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs @@ -0,0 +1,15 @@ +using Bit.Core.Tools.Models.Data; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public interface ISendAuthenticationMethodValidator where T : SendAuthenticationMethod +{ + /// + /// + /// request context + /// SendAuthenticationRecord that contains the information to be compared against the context + /// the sendId being accessed + /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success + Task ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId); +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs deleted file mode 100644 index a6f33175bd..0000000000 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Tools.Models.Data; -using Duende.IdentityServer.Validation; - -namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; - -public interface ISendPasswordRequestValidator -{ - /// - /// Validates the send password hash against the client hashed password. - /// If this method fails then it will automatically set the context.Result to an invalid grant result. - /// - /// request context - /// resource password authentication method containing the hash of the Send being retrieved - /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success - GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId); -} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index 952f4146ed..fae7ba4215 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -1,4 +1,5 @@ -using Duende.IdentityServer.Validation; +using Bit.Core.Auth.Identity.TokenProviders; +using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; @@ -34,7 +35,7 @@ public static class SendAccessConstants public static class GrantValidatorResults { /// - /// The sendId is valid and the request is well formed. + /// The sendId is valid and the request is well formed. Not returned in any response. /// public const string ValidSendGuid = "valid_send_guid"; /// @@ -66,8 +67,40 @@ public static class SendAccessConstants /// public const string EmailRequired = "email_required"; /// + /// Represents the error code indicating that an email address is invalid. + /// + public const string EmailInvalid = "email_invalid"; + /// /// Represents the status indicating that both email and OTP are required, and the OTP has been sent. /// public const string EmailOtpSent = "email_and_otp_required_otp_sent"; + /// + /// Represents the status indicating that both email and OTP are required, and the OTP is invalid. + /// + public const string EmailOtpInvalid = "otp_invalid"; + /// + /// For what ever reason the OTP was not able to be generated + /// + public const string OtpGenerationFailed = "otp_generation_failed"; + } + + /// + /// These are the constants for the OTP token that is generated during the email otp authentication process. + /// These items are required by to aid in the creation of a unique lookup key. + /// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier} + /// + public static class OtpToken + { + public const string TokenProviderName = "send_access"; + public const string Purpose = "email_otp"; + /// + /// This will be send_id {0} and email {1} + /// + public const string TokenUniqueIdentifier = "{0}_{1}"; + } + + public static class OtpEmail + { + public const string Subject = "Your Bitwarden Send verification code is {0}"; } } diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 7cfa2acd2a..5fe0b7b724 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, - ISendPasswordRequestValidator _sendPasswordRequestValidator, + ISendAuthenticationMethodValidator _sendPasswordRequestValidator, + ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, IFeatureService _featureService) : IExtensionGrantValidator { @@ -61,16 +62,14 @@ public class SendAccessGrantValidator( // automatically issue access token context.Result = BuildBaseSuccessResult(sendIdGuid); return; - case ResourcePassword rp: - // Validate if the password is correct, or if we need to respond with a 400 stating a password has is required - context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid); + // Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required. + context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid); return; case EmailOtp eo: - // TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request. - // SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails); - // break; - + // Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure. + context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid); + return; default: // shouldn’t ever hit this throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); @@ -114,28 +113,27 @@ public class SendAccessGrantValidator( /// /// Builds an error result for the specified error type. /// - /// The error type. + /// This error is a constant string from /// The error result. private static GrantValidationResult BuildErrorResult(string error) { + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }; + return error switch { // Request is the wrong shape SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult( TokenRequestErrors.InvalidRequest, - errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId], - new Dictionary - { - { SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId} - }), + errorDescription: _sendGrantValidatorErrorDescriptions[error], + customResponse), // Request is correct shape but data is bad SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult( TokenRequestErrors.InvalidGrant, - errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId], - new Dictionary - { - { SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId } - }), + errorDescription: _sendGrantValidatorErrorDescriptions[error], + customResponse), // should never get here _ => new GrantValidationResult(TokenRequestErrors.InvalidRequest) }; @@ -145,7 +143,7 @@ public class SendAccessGrantValidator( { var claims = new List { - new(Claims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.SendId, sendId.ToString()), new(Claims.Type, IdentityClientType.Send.ToString()) }; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs new file mode 100644 index 0000000000..e26556eb80 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -0,0 +1,134 @@ +using System.Security.Claims; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Identity; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public class SendEmailOtpRequestValidator( + IOtpTokenProvider otpTokenProvider, + IMailService mailService) : ISendAuthenticationMethodValidator +{ + + /// + /// static object that contains the error messages for the SendEmailOtpRequestValidator. + /// + private static readonly Dictionary _sendEmailOtpValidatorErrorDescriptions = new() + { + { SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." }, + }; + + public async Task ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId) + { + var request = context.Request.Raw; + // get email + var email = request.Get(SendAccessConstants.TokenRequest.Email); + + // It is an invalid request if the email is missing which indicated bad shape. + if (string.IsNullOrEmpty(email)) + { + // Request is the wrong shape and doesn't contain an email field. + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); + } + + // email must be in the list of emails in the EmailOtp array + if (!authMethod.Emails.Contains(email)) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); + } + + // get otp from request + var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp); + var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + if (string.IsNullOrEmpty(requestOtp)) + { + // Since the request doesn't have an OTP, generate one + var token = await otpTokenProvider.GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + uniqueIdentifierForTokenCache); + + // Verify that the OTP is generated + if (string.IsNullOrEmpty(token)) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); + } + + await mailService.SendSendEmailOtpEmailAsync( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); + } + + // validate request otp + var otpResult = await otpTokenProvider.ValidateTokenAsync( + requestOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + uniqueIdentifierForTokenCache); + + // If OTP is invalid return error result + if (!otpResult) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); + } + + return BuildSuccessResult(sendId, email!); + } + + private static GrantValidationResult BuildErrorResult(string error) + { + switch (error) + { + case SendAccessConstants.EmailOtpValidatorResults.EmailRequired: + case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent: + return new GrantValidationResult(TokenRequestErrors.InvalidRequest, + errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], + new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }); + case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid: + case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid: + return new GrantValidationResult( + TokenRequestErrors.InvalidGrant, + errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], + new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }); + default: + return new GrantValidationResult( + TokenRequestErrors.InvalidRequest, + errorDescription: error); + } + } + + /// + /// Builds a successful validation result for the Send password send_access grant. + /// + /// Guid of the send being accessed. + /// successful grant validation result + private static GrantValidationResult BuildSuccessResult(Guid sendId, string email) + { + var claims = new List + { + new(Claims.SendAccessClaims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.Email, email), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs index 3449b4cb56..4eade01a49 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs @@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; -public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator +public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator { private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher; @@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher { SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." } }; - public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) + public Task ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) { var request = context.Request.Raw; var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword); @@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher if (clientHashedPassword == null) { // Request is the wrong shape and doesn't contain a passwordHashB64 field. - return new GrantValidationResult( + return Task.FromResult(new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired], new Dictionary { { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired } - }); + })); } // _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call. @@ -46,16 +46,16 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher if (!hashMatches) { // Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty. - return new GrantValidationResult( + return Task.FromResult(new GrantValidationResult( TokenRequestErrors.InvalidGrant, errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch], new Dictionary { { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch } - }); + })); } - return BuildSendPasswordSuccessResult(sendId); + return Task.FromResult(BuildSendPasswordSuccessResult(sendId)); } /// @@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher { var claims = new List { - new(Claims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.SendId, sendId.ToString()), new(Claims.Type, IdentityClientType.Send.ToString()) }; diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index d4f2ad8045..95c067d884 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.IdentityServer; using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.ClientProviders; @@ -26,7 +27,8 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient, SendPasswordRequestValidator>(); + services.AddTransient, SendEmailOtpRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs index 27a0bc1bbc..bf5322d916 100644 --- a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs +++ b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs @@ -12,7 +12,7 @@ public class SendAccessClaimsPrincipalExtensionsTests { // Arrange var guid = Guid.NewGuid(); - var claims = new[] { new Claim(Claims.SendId, guid.ToString()) }; + var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, guid.ToString()) }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); // Act @@ -30,19 +30,19 @@ public class SendAccessClaimsPrincipalExtensionsTests // Act & Assert var ex = Assert.Throws(() => principal.GetSendId()); - Assert.Equal("Send ID claim not found.", ex.Message); + Assert.Equal("send_id claim not found.", ex.Message); } [Fact] public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid() { // Arrange - var claims = new[] { new Claim(Claims.SendId, "not-a-guid") }; + var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, "not-a-guid") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); // Act & Assert var ex = Assert.Throws(() => principal.GetSendId()); - Assert.Equal("Invalid Send ID claim value.", ex.Message); + Assert.Equal("Invalid send_id claim value.", ex.Message); } [Fact] diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 849a5130a3..242bcc60f3 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -247,11 +247,18 @@ public class HandlebarsMailServiceTests } } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. [Fact] - public void ServiceExists() + public async Task SendSendEmailOtpEmailAsync_SendsEmail() { - Assert.NotNull(_sut); + // Arrange + var email = "test@example.com"; + var token = "aToken"; + var subject = string.Format("Your Bitwarden Send verification code is {0}", token); + + // Act + await _sut.SendSendEmailOtpEmailAsync(email, token, subject); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs index 4b8c267861..3b0cf2c282 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs @@ -213,8 +213,8 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory services.AddSingleton(sendAuthQuery); // Mock password validator to return success - var passwordValidator = Substitute.For(); - passwordValidator.ValidateSendPassword( + var passwordValidator = Substitute.For>(); + passwordValidator.ValidateRequestAsync( Arg.Any(), Arg.Any(), Arg.Any()) diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs new file mode 100644 index 0000000000..9d9bc03ef5 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -0,0 +1,256 @@ +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Enums; +using Bit.Core.IdentityServer; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.IntegrationTestCommon.Factories; +using Duende.IdentityModel; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.IntegrationTest.RequestValidation; + +public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture +{ + private readonly IdentityApplicationFactory _factory; + + public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest() + { + // Arrange + var sendId = Guid.NewGuid(); + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(["test@example.com"])); + services.AddSingleton(sendAuthQuery); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId); // No email + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + Assert.Contains("email is required", content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_EmailWithoutOtp_SendsOtpEmail() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + var generatedToken = "123456"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp([email])); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider + var otpProvider = Substitute.For>(); + otpProvider.GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(generatedToken); + services.AddSingleton(otpProvider); + + // Mock mail service + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + Assert.Contains("email otp sent", content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_ValidOtp_ReturnsAccessToken() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + var otp = "123456"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(new[] { email })); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider to validate successfully + var otpProvider = Substitute.For>(); + otpProvider.ValidateTokenAsync(otp, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + services.AddSingleton(otpProvider); + + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: otp); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + Assert.True(response.IsSuccessStatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenResponse.AccessToken, content); + Assert.Contains(OidcConstants.TokenResponse.BearerTokenType, content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGrant() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + var invalidOtp = "wrong123"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(new[] { email })); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider to validate as false + var otpProvider = Substitute.For>(); + otpProvider.ValidateTokenAsync(invalidOtp, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + services.AddSingleton(otpProvider); + + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: invalidOtp); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + Assert.Contains("email otp is invalid", content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_OtpGenerationFails_ReturnsInvalidRequest() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(new[] { email })); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider to fail generation + var otpProvider = Substitute.For>(); + otpProvider.GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string)null); + services.AddSingleton(otpProvider); + + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + } + + private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId, + string sendEmail = null, string emailOtp = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + var parameters = new List> + { + new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), + new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ), + new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), + new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), + new(SendAccessConstants.TokenRequest.SendId, sendIdBase64) + }; + + if (!string.IsNullOrEmpty(sendEmail)) + { + parameters.Add(new KeyValuePair( + SendAccessConstants.TokenRequest.Email, sendEmail)); + } + + if (!string.IsNullOrEmpty(emailOtp)) + { + parameters.Add(new KeyValuePair( + SendAccessConstants.TokenRequest.Otp, emailOtp)); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs similarity index 90% rename from test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs rename to test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index c3d422c51a..e651709c47 100644 --- a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -17,7 +17,7 @@ using Duende.IdentityServer.Validation; using NSubstitute; using Xunit; -namespace Bit.Identity.Test.IdentityServer; +namespace Bit.Identity.Test.IdentityServer.SendAccess; [SutProviderCustomize] public class SendAccessGrantValidatorTests @@ -167,7 +167,7 @@ public class SendAccessGrantValidatorTests // get the claims from the subject var claims = subject.Claims.ToList(); Assert.NotEmpty(claims); - Assert.Contains(claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString()); + Assert.Contains(claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); } @@ -189,8 +189,8 @@ public class SendAccessGrantValidatorTests .GetAuthenticationMethod(sendId) .Returns(resourcePassword); - sutProvider.GetDependency() - .ValidateSendPassword(context, resourcePassword, sendId) + sutProvider.GetDependency>() + .ValidateRequestAsync(context, resourcePassword, sendId) .Returns(expectedResult); // Act @@ -198,15 +198,16 @@ public class SendAccessGrantValidatorTests // Assert Assert.Equal(expectedResult, context.Result); - sutProvider.GetDependency() + await sutProvider.GetDependency>() .Received(1) - .ValidateSendPassword(context, resourcePassword, sendId); + .ValidateRequestAsync(context, resourcePassword, sendId); } [Theory, BitAutoData] - public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError( + public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp( [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, SutProvider sutProvider, + GrantValidationResult expectedResult, Guid sendId, EmailOtp emailOtp) { @@ -216,15 +217,22 @@ public class SendAccessGrantValidatorTests sendId, tokenRequest); - sutProvider.GetDependency() .GetAuthenticationMethod(sendId) .Returns(emailOtp); + sutProvider.GetDependency>() + .ValidateRequestAsync(context, emailOtp, sendId) + .Returns(expectedResult); + // Act + await sutProvider.Sut.ValidateAsync(context); + // Assert - // Currently the EmailOtp case doesn't set a result, so it should be null - await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(context)); + Assert.Equal(expectedResult, context.Result); + await sutProvider.GetDependency>() + .Received(1) + .ValidateRequestAsync(context, emailOtp, sendId); } [Theory, BitAutoData] @@ -256,7 +264,7 @@ public class SendAccessGrantValidatorTests public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs new file mode 100644 index 0000000000..2fd21fd4cf --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -0,0 +1,310 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Enums; +using Bit.Core.Identity; +using Bit.Core.IdentityServer; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendEmailOtpRequestValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateRequestAsync_MissingEmail_ReturnsInvalidRequest( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal("email is required.", result.ErrorDescription); + + // Verify no OTP generation or email sending occurred + await sutProvider.GetDependency>() + .DidNotReceive() + .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + string email, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + var emailOTP = new EmailOtp(["user@test.dev"]); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal("email is invalid.", result.ErrorDescription); + + // Verify no OTP generation or email sending occurred + await sutProvider.GetDependency>() + .DidNotReceive() + .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email, + string generatedToken) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + + sutProvider.GetDependency>() + .GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId) + .Returns(generatedToken); + + emailOtp = emailOtp with { Emails = [email] }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal("email otp sent.", result.ErrorDescription); + + // Verify OTP generation + await sutProvider.GetDependency>() + .Received(1) + .GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId); + + // Verify email sending + await sutProvider.GetDependency() + .Received(1) + .SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + emailOtp = emailOtp with { Emails = [email] }; + + sutProvider.GetDependency>() + .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string)null); // Generation fails + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + + // Verify no email was sent + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email, + string otp) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + emailOtp = emailOtp with { Emails = [email] }; + + var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + + sutProvider.GetDependency>() + .ValidateTokenAsync( + otp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.False(result.IsError); + var sub = result.Subject; + Assert.Equal(sendId.ToString(), sub.Claims.First(c => c.Type == Claims.SendAccessClaims.SendId).Value); + + // Verify claims + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.Email && c.Value == email); + Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); + + // Verify OTP validation was called + await sutProvider.GetDependency>() + .Received(1) + .ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId); + + // Verify no email was sent (validation only) + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email, + string invalidOtp) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + emailOtp = emailOtp with { Emails = [email] }; + + var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + + sutProvider.GetDependency>() + .ValidateTokenAsync(invalidOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal("email otp is invalid.", result.ErrorDescription); + + // Verify OTP validation was attempted + await sutProvider.GetDependency>() + .Received(1) + .ValidateTokenAsync(invalidOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + var otpTokenProvider = Substitute.For>(); + var mailService = Substitute.For(); + + // Act + var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService); + + // Assert + Assert.NotNull(validator); + } + + private static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + string sendEmail = null, + string otpCode = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); + } + + if (otpCode != null && sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs new file mode 100644 index 0000000000..e2b8b49830 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -0,0 +1,297 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.UserFeatures.SendAccess; +using Bit.Core.Enums; +using Bit.Core.Identity; +using Bit.Core.IdentityServer; +using Bit.Core.KeyManagement.Sends; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendPasswordRequestValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required.", result.ErrorDescription); + + // Verify password hasher was not called + sutProvider.GetDependency() + .DidNotReceive() + .PasswordHashMatches(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId, + string clientPasswordHash) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is invalid.", result.ErrorDescription); + + // Verify password hasher was called with correct parameters + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId, + string clientPasswordHash) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.False(result.IsError); + + var sub = result.Subject; + Assert.Equal(sendId, sub.GetSendId()); + + // Verify claims + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); + Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); + + // Verify password hasher was called + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, string.Empty) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + + // Verify password hasher was called with empty string + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, string.Empty); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + var whitespacePassword = " "; + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, whitespacePassword) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + + // Verify password hasher was called with whitespace string + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, whitespacePassword); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + var firstPassword = "first-password"; + var secondPassword = "second-password"; + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, firstPassword) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + + // Verify password hasher was called with first value + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, $"{firstPassword},{secondPassword}"); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId, + string clientPasswordHash) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.False(result.IsError); + var sub = result.Subject; + + var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId); + Assert.NotNull(sendIdClaim); + Assert.Equal(sendId.ToString(), sendIdClaim.Value); + + var typeClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.Type); + Assert.NotNull(typeClaim); + Assert.Equal(IdentityClientType.Send.ToString(), typeClaim.Value); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + var sendPasswordHasher = Substitute.For(); + + // Act + var validator = new SendPasswordRequestValidator(sendPasswordHasher); + + // Assert + Assert.NotNull(validator); + } + + private static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + params string[] passwordHash) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (passwordHash != null && passwordHash.Length > 0) + { + foreach (var hash in passwordHash) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); + } + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs index a776a70178..ccee33d8c7 100644 --- a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs @@ -21,7 +21,7 @@ namespace Bit.Identity.Test.IdentityServer; public class SendPasswordRequestValidatorTests { [Theory, BitAutoData] - public void ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( + public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -36,7 +36,7 @@ public class SendPasswordRequestValidatorTests }; // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -50,7 +50,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( + public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -70,7 +70,7 @@ public class SendPasswordRequestValidatorTests .Returns(false); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -84,7 +84,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( + public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -104,7 +104,7 @@ public class SendPasswordRequestValidatorTests .Returns(true); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.False(result.IsError); @@ -113,7 +113,7 @@ public class SendPasswordRequestValidatorTests Assert.Equal(sendId, sub.GetSendId()); // Verify claims - Assert.Contains(sub.Claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString()); + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); // Verify password hasher was called @@ -123,7 +123,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( + public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -142,7 +142,7 @@ public class SendPasswordRequestValidatorTests .Returns(false); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -155,7 +155,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( + public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -175,7 +175,7 @@ public class SendPasswordRequestValidatorTests .Returns(false); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -187,7 +187,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( + public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -208,7 +208,7 @@ public class SendPasswordRequestValidatorTests .Returns(true); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -221,7 +221,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_SuccessResult_ContainsCorrectClaims( + public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -241,13 +241,13 @@ public class SendPasswordRequestValidatorTests .Returns(true); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.False(result.IsError); var sub = result.Subject; - var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendId); + var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId); Assert.NotNull(sendIdClaim); Assert.Equal(sendId.ToString(), sendIdClaim.Value); From 0bfbfaa17c36373b8cc16ea4a81c8979886b533f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 3 Sep 2025 11:38:01 +0200 Subject: [PATCH 21/85] Improve Swagger OperationIDs for Tools (#6239) --- .../Controllers/ImportCiphersController.cs | 2 +- src/Api/Tools/Controllers/SendsController.cs | 2 +- .../ImportCiphersControllerTests.cs | 20 +++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 0f29a9aee3..88028420b7 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -63,7 +63,7 @@ public class ImportCiphersController : Controller } [HttpPost("import-organization")] - public async Task PostImport([FromQuery] string organizationId, + public async Task PostImportOrganization([FromQuery] string organizationId, [FromBody] ImportOrganizationCiphersRequestModel model) { if (!_globalSettings.SelfHosted && diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 43239b3995..c02e9b0c20 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -192,7 +192,7 @@ public class SendsController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var sends = await _sendRepository.GetManyByUserIdAsync(userId); diff --git a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs index 53d9d2a1f8..4908bb6847 100644 --- a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs @@ -126,7 +126,7 @@ public class ImportCiphersControllerTests }; // Act - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostImport(Arg.Any(), model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostImportOrganization(Arg.Any(), model)); // Assert Assert.Equal("You cannot import this much data at once.", exception.Message); @@ -186,7 +186,7 @@ public class ImportCiphersControllerTests .Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList()); // Act - await sutProvider.Sut.PostImport(orgId, request); + await sutProvider.Sut.PostImportOrganization(orgId, request); // Assert await sutProvider.GetDependency() @@ -257,7 +257,7 @@ public class ImportCiphersControllerTests .Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList()); // Act - await sutProvider.Sut.PostImport(orgId, request); + await sutProvider.Sut.PostImportOrganization(orgId, request); // Assert await sutProvider.GetDependency() @@ -324,7 +324,7 @@ public class ImportCiphersControllerTests // Act var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.PostImport(orgId, request)); + sutProvider.Sut.PostImportOrganization(orgId, request)); // Assert Assert.IsType(exception); @@ -387,7 +387,7 @@ public class ImportCiphersControllerTests // Act var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.PostImport(orgId, request)); + sutProvider.Sut.PostImportOrganization(orgId, request)); // Assert Assert.IsType(exception); @@ -457,7 +457,7 @@ public class ImportCiphersControllerTests // Act // User imports into collections and creates new collections // User has ImportCiphers and Create ciphers permission - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() @@ -535,7 +535,7 @@ public class ImportCiphersControllerTests // User has ImportCiphers permission only and doesn't have Create permission var exception = await Assert.ThrowsAsync(async () => { - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); }); // Assert @@ -610,7 +610,7 @@ public class ImportCiphersControllerTests // Act // User imports/creates a new collection - existing collections not affected // User has create permissions and doesn't need import permissions - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() @@ -685,7 +685,7 @@ public class ImportCiphersControllerTests // Act // User import into existing collection // User has ImportCiphers permission only and doesn't need create permission - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() @@ -753,7 +753,7 @@ public class ImportCiphersControllerTests // import ciphers only and no collections // User has Create permissions // expected to be successful - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() From d627b0a0643650bb39132985c63ea0dfd4d253ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:01:39 +0200 Subject: [PATCH 22/85] [deps] Tools: Update aws-sdk-net monorepo (#6272) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 25e74d8aee..04dd7781bc 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 99058891d0ba39a7a6901799e81a6472b3556d88 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 3 Sep 2025 09:12:26 -0400 Subject: [PATCH 23/85] Auth/pm 24434/enhance email (#6157) * fix(emails): [PM-24434] Email Enhancement - Added seconds to new device logged in email --- src/Core/Services/Implementations/HandlebarsMailService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 394b5c5125..8de0e99bd3 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -559,7 +559,7 @@ public class HandlebarsMailService : IMailService SiteName = _globalSettings.SiteName, DeviceType = deviceType, TheDate = timestamp.ToLongDateString(), - TheTime = timestamp.ToShortTimeString(), + TheTime = timestamp.ToString("hh:mm:ss tt"), TimeZone = _utcTimeZoneDisplay, IpAddress = ip }; From 1dade9d4b868fb73907c0d280fd19bb0191692cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:57:53 +0100 Subject: [PATCH 24/85] [PM-24233] Use BulkResourceCreationService in CipherRepository (#6201) * Add constant for CipherRepositoryBulkResourceCreation in FeatureFlagKeys * Add bulk creation methods for Ciphers, Folders, and CollectionCiphers in BulkResourceCreationService - Implemented CreateCiphersAsync, CreateFoldersAsync, CreateCollectionCiphersAsync, and CreateTempCiphersAsync methods for bulk insertion. - Added helper methods to build DataTables for Ciphers, Folders, and CollectionCiphers. - Enhanced error handling for empty collections during bulk operations. * Refactor CipherRepository to utilize BulkResourceCreationService - Introduced IFeatureService to manage feature flag checks for bulk operations. - Updated methods to conditionally use BulkResourceCreationService for creating Ciphers, Folders, and CollectionCiphers based on feature flag status. - Enhanced existing bulk copy logic to maintain functionality while integrating feature flag checks. * Add InlineFeatureService to DatabaseDataAttribute for feature flag management - Introduced EnabledFeatureFlags property to DatabaseDataAttribute for configuring feature flags. - Integrated InlineFeatureService to provide feature flag checks within the service collection. - Enhanced GetData method to utilize feature flags for conditional service registration. * Add tests for bulk creation of Ciphers in CipherRepositoryTests - Implemented tests for bulk creation of Ciphers, Folders, and Collections with feature flag checks. - Added test cases for updating multiple Ciphers to validate bulk update functionality. - Enhanced existing test structure to ensure comprehensive coverage of bulk operations in the CipherRepository. * Refactor BulkResourceCreationService to use dynamic types for DataColumns - Updated DataColumn definitions in BulkResourceCreationService to utilize the actual types of properties from the cipher object instead of hardcoded types. - Simplified the assignment of nullable properties to directly use their values, improving code readability and maintainability. * Update BulkResourceCreationService to use specific types for DataColumns - Changed DataColumn definitions to use specific types (short and string) instead of dynamic types based on cipher properties. - Improved handling of nullable properties when assigning values to DataTable rows, ensuring proper handling of DBNull for null values. * Refactor CipherRepositoryTests for improved clarity and consistency - Renamed test methods to better reflect their purpose and improve readability. - Updated test data to use more descriptive names for users, folders, and collections. - Enhanced test structure with clear Arrange, Act, and Assert sections for better understanding of test flow. - Ensured all tests validate the expected outcomes for bulk operations with feature flag checks. * Update CipherRepositoryBulkResourceCreation feature flag key * Refactor DatabaseDataAttribute usage in CipherRepositoryTests to use array syntax for EnabledFeatureFlags * Update CipherRepositoryTests to use GenerateComb for generating unique IDs * Refactor CipherRepository methods to accept a boolean parameter for enabling bulk resource creation based on feature flags. Update tests to verify functionality with and without the feature flag enabled. * Refactor CipherRepository and related services to support new methods for bulk resource creation without boolean parameters. --- src/Core/Constants.cs | 1 + .../RotateUserAccountkeysCommand.cs | 15 +- .../ImportFeatures/ImportCiphersCommand.cs | 20 +- .../Vault/Repositories/ICipherRepository.cs | 22 ++ .../Services/Implementations/CipherService.cs | 10 +- .../Helpers/BulkResourceCreationService.cs | 190 ++++++++++++++++ .../Vault/Repositories/CipherRepository.cs | 211 ++++++++++++++++++ .../Vault/Repositories/CipherRepository.cs | 41 ++++ .../ImportCiphersAsyncCommandTests.cs | 136 ++++++++++- .../Vault/Services/CipherServiceTests.cs | 53 +++++ .../Repositories/CipherRepositoryTests.cs | 157 +++++++++++++ 11 files changed, 849 insertions(+), 7 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 393ab15e4c..2993f6a094 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -114,6 +114,7 @@ public static class FeatureFlagKeys public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; + public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs index 6967c9bf85..011fc2932f 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs @@ -25,6 +25,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand private readonly IdentityErrorDescriber _identityErrorDescriber; private readonly IWebAuthnCredentialRepository _credentialRepository; private readonly IPasswordHasher _passwordHasher; + private readonly IFeatureService _featureService; /// /// Instantiates a new @@ -45,7 +46,8 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, IDeviceRepository deviceRepository, IPasswordHasher passwordHasher, - IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository) + IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository, + IFeatureService featureService) { _userService = userService; _userRepository = userRepository; @@ -59,6 +61,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand _identityErrorDescriber = errors; _credentialRepository = credentialRepository; _passwordHasher = passwordHasher; + _featureService = featureService; } /// @@ -100,7 +103,15 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand List saveEncryptedDataActions = new(); if (model.Ciphers.Any()) { - saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation_vNext(user.Id, model.Ciphers)); + } + else + { + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); + } } if (model.Folders.Any()) diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index c7f7e3aff7..ce269bc68c 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -108,7 +108,15 @@ public class ImportCiphersCommand : IImportCiphersCommand } // Create it all - await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + await _cipherRepository.CreateAsync_vNext(importingUserId, ciphers, newFolders); + } + else + { + await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders); + } // push await _pushService.PushSyncVaultAsync(importingUserId); @@ -183,7 +191,15 @@ public class ImportCiphersCommand : IImportCiphersCommand } // Create it all - await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + await _cipherRepository.CreateAsync_vNext(ciphers, newCollections, collectionCiphers, newCollectionUsers); + } + else + { + await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers); + } // push await _pushService.PushSyncVaultAsync(importingUserId); diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index 5a04a6651d..60b6e21f1d 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -32,12 +32,28 @@ public interface ICipherRepository : IRepository Task DeleteByUserIdAsync(Guid userId); Task DeleteByOrganizationIdAsync(Guid organizationId); Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers); + /// + /// + /// This version uses the bulk resource creation service to create the temp table. + /// + Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable ciphers); /// /// Create ciphers and folders for the specified UserId. Must not be used to create organization owned items. /// Task CreateAsync(Guid userId, IEnumerable ciphers, IEnumerable folders); + /// + /// + /// This version uses the bulk resource creation service to create the temp tables. + /// + Task CreateAsync_vNext(Guid userId, IEnumerable ciphers, IEnumerable folders); Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers, IEnumerable collectionUsers); + /// + /// + /// This version uses the bulk resource creation service to create the temp tables. + /// + Task CreateAsync_vNext(IEnumerable ciphers, IEnumerable collections, + IEnumerable collectionCiphers, IEnumerable collectionUsers); Task SoftDeleteAsync(IEnumerable ids, Guid userId); Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task RestoreAsync(IEnumerable ids, Guid userId); @@ -68,4 +84,10 @@ public interface ICipherRepository : IRepository /// A list of ciphers with updated data UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable ciphers); + /// + /// + /// This version uses the bulk resource creation service to create the temp table. + /// + UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(Guid userId, + IEnumerable ciphers); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 51ed4b0ce7..2a4cc6c137 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -642,7 +642,15 @@ public class CipherService : ICipherService cipherIds.Add(cipher.Id); } - await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher)); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + await _cipherRepository.UpdateCiphersAsync_vNext(sharingUserId, cipherInfos.Select(c => c.cipher)); + } + else + { + await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher)); + } await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId, organizationId, collectionIds); diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 139960ceba..3610c1c484 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -1,5 +1,6 @@ using System.Data; using Bit.Core.Entities; +using Bit.Core.Vault.Entities; using Microsoft.Data.SqlClient; namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; @@ -15,6 +16,38 @@ public static class BulkResourceCreationService await bulkCopy.WriteToServerAsync(dataTable); } + public static async Task CreateCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable ciphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Cipher]"; + var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateFoldersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable folders, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Folder]"; + var dataTable = BuildFoldersTable(bulkCopy, folders, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateCollectionCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collectionCiphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[CollectionCipher]"; + var dataTable = BuildCollectionCiphersTable(bulkCopy, collectionCiphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateTempCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable ciphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "#TempCipher"; + var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + private static DataTable BuildCollectionsUsersTable(SqlBulkCopy bulkCopy, IEnumerable collectionUsers, string errorMessage) { var collectionUser = collectionUsers.FirstOrDefault(); @@ -126,4 +159,161 @@ public static class BulkResourceCreationService return collectionsTable; } + + private static DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable ciphers, string errorMessage) + { + var c = ciphers.FirstOrDefault(); + + if (c == null) + { + throw new ApplicationException(errorMessage); + } + + var ciphersTable = new DataTable("CipherDataTable"); + + var idColumn = new DataColumn(nameof(c.Id), c.Id.GetType()); + ciphersTable.Columns.Add(idColumn); + var userIdColumn = new DataColumn(nameof(c.UserId), typeof(Guid)); + ciphersTable.Columns.Add(userIdColumn); + var organizationId = new DataColumn(nameof(c.OrganizationId), typeof(Guid)); + ciphersTable.Columns.Add(organizationId); + var typeColumn = new DataColumn(nameof(c.Type), typeof(short)); + ciphersTable.Columns.Add(typeColumn); + var dataColumn = new DataColumn(nameof(c.Data), typeof(string)); + ciphersTable.Columns.Add(dataColumn); + var favoritesColumn = new DataColumn(nameof(c.Favorites), typeof(string)); + ciphersTable.Columns.Add(favoritesColumn); + var foldersColumn = new DataColumn(nameof(c.Folders), typeof(string)); + ciphersTable.Columns.Add(foldersColumn); + var attachmentsColumn = new DataColumn(nameof(c.Attachments), typeof(string)); + ciphersTable.Columns.Add(attachmentsColumn); + var creationDateColumn = new DataColumn(nameof(c.CreationDate), c.CreationDate.GetType()); + ciphersTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(c.RevisionDate), c.RevisionDate.GetType()); + ciphersTable.Columns.Add(revisionDateColumn); + var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime)); + ciphersTable.Columns.Add(deletedDateColumn); + var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short)); + ciphersTable.Columns.Add(repromptColumn); + var keyColummn = new DataColumn(nameof(c.Key), typeof(string)); + ciphersTable.Columns.Add(keyColummn); + + foreach (DataColumn col in ciphersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + ciphersTable.PrimaryKey = keys; + + foreach (var cipher in ciphers) + { + var row = ciphersTable.NewRow(); + + row[idColumn] = cipher.Id; + row[userIdColumn] = cipher.UserId.HasValue ? (object)cipher.UserId.Value : DBNull.Value; + row[organizationId] = cipher.OrganizationId.HasValue ? (object)cipher.OrganizationId.Value : DBNull.Value; + row[typeColumn] = (short)cipher.Type; + row[dataColumn] = cipher.Data; + row[favoritesColumn] = cipher.Favorites; + row[foldersColumn] = cipher.Folders; + row[attachmentsColumn] = cipher.Attachments; + row[creationDateColumn] = cipher.CreationDate; + row[revisionDateColumn] = cipher.RevisionDate; + row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; + row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; + row[keyColummn] = cipher.Key; + + ciphersTable.Rows.Add(row); + } + + return ciphersTable; + } + + private static DataTable BuildFoldersTable(SqlBulkCopy bulkCopy, IEnumerable folders, string errorMessage) + { + var f = folders.FirstOrDefault(); + + if (f == null) + { + throw new ApplicationException(errorMessage); + } + + var foldersTable = new DataTable("FolderDataTable"); + + var idColumn = new DataColumn(nameof(f.Id), f.Id.GetType()); + foldersTable.Columns.Add(idColumn); + var userIdColumn = new DataColumn(nameof(f.UserId), f.UserId.GetType()); + foldersTable.Columns.Add(userIdColumn); + var nameColumn = new DataColumn(nameof(f.Name), typeof(string)); + foldersTable.Columns.Add(nameColumn); + var creationDateColumn = new DataColumn(nameof(f.CreationDate), f.CreationDate.GetType()); + foldersTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(f.RevisionDate), f.RevisionDate.GetType()); + foldersTable.Columns.Add(revisionDateColumn); + + foreach (DataColumn col in foldersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + foldersTable.PrimaryKey = keys; + + foreach (var folder in folders) + { + var row = foldersTable.NewRow(); + + row[idColumn] = folder.Id; + row[userIdColumn] = folder.UserId; + row[nameColumn] = folder.Name; + row[creationDateColumn] = folder.CreationDate; + row[revisionDateColumn] = folder.RevisionDate; + + foldersTable.Rows.Add(row); + } + + return foldersTable; + } + + private static DataTable BuildCollectionCiphersTable(SqlBulkCopy bulkCopy, IEnumerable collectionCiphers, string errorMessage) + { + var cc = collectionCiphers.FirstOrDefault(); + + if (cc == null) + { + throw new ApplicationException(errorMessage); + } + + var collectionCiphersTable = new DataTable("CollectionCipherDataTable"); + + var collectionIdColumn = new DataColumn(nameof(cc.CollectionId), cc.CollectionId.GetType()); + collectionCiphersTable.Columns.Add(collectionIdColumn); + var cipherIdColumn = new DataColumn(nameof(cc.CipherId), cc.CipherId.GetType()); + collectionCiphersTable.Columns.Add(cipherIdColumn); + + foreach (DataColumn col in collectionCiphersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[2]; + keys[0] = collectionIdColumn; + keys[1] = cipherIdColumn; + collectionCiphersTable.PrimaryKey = keys; + + foreach (var collectionCipher in collectionCiphers) + { + var row = collectionCiphersTable.NewRow(); + + row[collectionIdColumn] = collectionCipher.CollectionId; + row[cipherIdColumn] = collectionCipher.CipherId; + + collectionCiphersTable.Rows.Add(row); + } + + return collectionCiphersTable; + } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 180a90fd41..8c1f04affc 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -10,6 +10,7 @@ using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Bit.Infrastructure.Dapper.Repositories; using Bit.Infrastructure.Dapper.Vault.Helpers; using Dapper; @@ -408,6 +409,52 @@ public class CipherRepository : Repository, ICipherRepository }; } + /// + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext( + Guid userId, IEnumerable ciphers) + { + return async (SqlConnection connection, SqlTransaction transaction) => + { + // Create temp table + var sqlCreateTemp = @" + SELECT TOP 0 * + INTO #TempCipher + FROM [dbo].[Cipher]"; + + await using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction)) + { + cmd.ExecuteNonQuery(); + } + + // Bulk copy data into temp table + await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers); + + // Update cipher table from temp table + var sql = @" + UPDATE + [dbo].[Cipher] + SET + [Data] = TC.[Data], + [Attachments] = TC.[Attachments], + [RevisionDate] = TC.[RevisionDate], + [Key] = TC.[Key] + FROM + [dbo].[Cipher] C + INNER JOIN + #TempCipher TC ON C.Id = TC.Id + WHERE + C.[UserId] = @UserId + + DROP TABLE #TempCipher"; + + await using (var cmd = new SqlCommand(sql, connection, transaction)) + { + cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId; + cmd.ExecuteNonQuery(); + } + }; + } + public async Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers) { if (!ciphers.Any()) @@ -490,6 +537,83 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable ciphers) + { + if (!ciphers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + // 1. Create temp tables to bulk copy into. + + var sqlCreateTemp = @" + SELECT TOP 0 * + INTO #TempCipher + FROM [dbo].[Cipher]"; + + using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction)) + { + cmd.ExecuteNonQuery(); + } + + // 2. Bulk copy into temp tables. + await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers); + + // 3. Insert into real tables from temp tables and clean up. + + // Intentionally not including Favorites, Folders, and CreationDate + // since those are not meant to be bulk updated at this time + var sql = @" + UPDATE + [dbo].[Cipher] + SET + [UserId] = TC.[UserId], + [OrganizationId] = TC.[OrganizationId], + [Type] = TC.[Type], + [Data] = TC.[Data], + [Attachments] = TC.[Attachments], + [RevisionDate] = TC.[RevisionDate], + [DeletedDate] = TC.[DeletedDate], + [Key] = TC.[Key] + FROM + [dbo].[Cipher] C + INNER JOIN + #TempCipher TC ON C.Id = TC.Id + WHERE + C.[UserId] = @UserId + + DROP TABLE #TempCipher"; + + using (var cmd = new SqlCommand(sql, connection, transaction)) + { + cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId; + cmd.ExecuteNonQuery(); + } + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDate]", + new { Id = userId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task CreateAsync(Guid userId, IEnumerable ciphers, IEnumerable folders) { if (!ciphers.Any()) @@ -538,6 +662,44 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task CreateAsync_vNext(Guid userId, IEnumerable ciphers, IEnumerable folders) + { + if (!ciphers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + if (folders.Any()) + { + await BulkResourceCreationService.CreateFoldersAsync(connection, transaction, folders); + } + + await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers); + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDate]", + new { Id = userId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers, IEnumerable collectionUsers) { @@ -607,6 +769,55 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task CreateAsync_vNext(IEnumerable ciphers, IEnumerable collections, + IEnumerable collectionCiphers, IEnumerable collectionUsers) + { + if (!ciphers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers); + + if (collections.Any()) + { + await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); + } + + if (collectionCiphers.Any()) + { + await BulkResourceCreationService.CreateCollectionCiphersAsync(connection, transaction, collectionCiphers); + } + + if (collectionUsers.Any()) + { + await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); + } + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDateByOrganizationId]", + new { OrganizationId = ciphers.First().OrganizationId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task SoftDeleteAsync(IEnumerable ids, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 3fae537a1e..d595fe7cfe 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -167,6 +167,16 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular create method. + /// + public async Task CreateAsync_vNext(Guid userId, IEnumerable ciphers, + IEnumerable folders) + { + await CreateAsync(userId, ciphers, folders); + } + public async Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers, @@ -205,6 +215,18 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular create method. + /// + public async Task CreateAsync_vNext(IEnumerable ciphers, + IEnumerable collections, + IEnumerable collectionCiphers, + IEnumerable collectionUsers) + { + await CreateAsync(ciphers, collections, collectionCiphers, collectionUsers); + } + public async Task DeleteAsync(IEnumerable ids, Guid userId) { await ToggleCipherStates(ids, userId, CipherStateAction.HardDelete); @@ -907,6 +929,15 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular update method. + /// + public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable ciphers) + { + await UpdateCiphersAsync(userId, ciphers); + } + public async Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -970,6 +1001,16 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular update method. + /// + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext( + Guid userId, IEnumerable ciphers) + { + return UpdateForKeyRotation(userId, ciphers); + } + public async Task UpsertAsync(CipherDetails cipher) { if (cipher.Id.Equals(default)) diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index 0cb0deaf52..11f637d207 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -47,7 +47,41 @@ public class ImportCiphersAsyncCommandTests await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); // Assert - await sutProvider.GetDependency().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_Success( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership) + .Returns(false); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List { new Folder { UserId = importingUserId } }; + + var folderRelationships = new List>(); + + // Act + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CreateAsync_vNext(importingUserId, ciphers, Arg.Any>()); await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } @@ -77,7 +111,45 @@ public class ImportCiphersAsyncCommandTests await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); - await sutProvider.GetDependency().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(importingUserId) + .Returns(new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Disabled, + [])); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List { new Folder { UserId = importingUserId } }; + + var folderRelationships = new List>(); + + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync_vNext(importingUserId, ciphers, Arg.Any>()); await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } @@ -187,6 +259,66 @@ public class ImportCiphersAsyncCommandTests await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } + [Theory, BitAutoData] + public async Task ImportIntoOrganizationalVaultAsync_WithBulkResourceCreationServiceEnabled_Success( + Organization organization, + Guid importingUserId, + OrganizationUser importingOrganizationUser, + List collections, + List ciphers, + SutProvider sutProvider) + { + organization.MaxCollections = null; + importingOrganizationUser.OrganizationId = organization.Id; + + foreach (var collection in collections) + { + collection.OrganizationId = organization.Id; + } + + foreach (var cipher in ciphers) + { + cipher.OrganizationId = organization.Id; + } + + KeyValuePair[] collectionRelationships = { + new(0, 0), + new(1, 1), + new(2, 2) + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, importingUserId) + .Returns(importingOrganizationUser); + + // Set up a collection that already exists in the organization + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns(new List { collections[0] }); + + await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId); + + await sutProvider.GetDependency().Received(1).CreateAsync_vNext( + ciphers, + Arg.Is>(cols => cols.Count() == collections.Count - 1 && + !cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added + cols.All(c => collections.Any(x => c.Name == x.Name))), + Arg.Is>(c => c.Count() == ciphers.Count), + Arg.Is>(cus => + cus.Count() == collections.Count - 1 && + !cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization + cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true))); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + [Theory, BitAutoData] public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException( Organization organization, diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 55db5a9143..44c86389e3 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -674,6 +674,32 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + [Theory] + [BitAutoData("")] + [BitAutoData("Correct Time")] + public async Task ShareManyAsync_CorrectRevisionDate_WithBulkResourceCreationServiceEnabled_Passes(string revisionDateString, + SutProvider sutProvider, IEnumerable ciphers, Organization organization, List collectionIds) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(organization.Id) + .Returns(new Organization + { + PlanType = PlanType.EnterpriseAnnually, + MaxStorageGb = 100 + }); + + var cipherInfos = ciphers.Select(c => (c, + string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId); + await sutProvider.GetDependency().Received(1).UpdateCiphersAsync_vNext(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + [Theory] [BitAutoData] public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider sutProvider) @@ -1094,6 +1120,33 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + [Theory, BitAutoData] + public async Task ShareManyAsync_PaidOrgWithAttachment_WithBulkResourceCreationServiceEnabled_Passes(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization + { + PlanType = PlanType.EnterpriseAnnually, + MaxStorageGb = 100 + }); + ciphers.FirstOrDefault().Attachments = + "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; + + var cipherInfos = ciphers.Select(c => (c, + (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId); + await sutProvider.GetDependency().Received(1).UpdateCiphersAsync_vNext(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + private class SaveDetailsAsyncDependencies { public CipherDetails CipherDetails { get; set; } diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index 0a186e43be..2a31398a02 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -8,11 +8,13 @@ using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Repositories; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Xunit; +using CipherType = Bit.Core.Vault.Enums.CipherType; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -975,6 +977,161 @@ public class CipherRepositoryTests Assert.Equal("new_attachments", updatedCipher2.Attachments); } + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_vNext_WithFolders_Works( + IUserRepository userRepository, ICipherRepository cipherRepository, IFolderRepository folderRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var folder1 = new Folder { Id = CoreHelpers.GenerateComb(), UserId = user.Id, Name = "Test Folder 1" }; + var folder2 = new Folder { Id = CoreHelpers.GenerateComb(), UserId = user.Id, Name = "Test Folder 2" }; + var cipher1 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" }; + var cipher2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.SecureNote, UserId = user.Id, Data = "" }; + + // Act + await cipherRepository.CreateAsync_vNext( + userId: user.Id, + ciphers: [cipher1, cipher2], + folders: [folder1, folder2]); + + // Assert + var readCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id); + var readCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id); + Assert.NotNull(readCipher1); + Assert.NotNull(readCipher2); + + var readFolder1 = await folderRepository.GetByIdAsync(folder1.Id); + var readFolder2 = await folderRepository.GetByIdAsync(folder2.Id); + Assert.NotNull(readFolder1); + Assert.NotNull(readFolder2); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_vNext_WithCollectionsAndUsers_Works( + IOrganizationRepository orgRepository, + IOrganizationUserRepository orgUserRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository, + ICipherRepository cipherRepository, + IUserRepository userRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var org = await orgRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var orgUser = await orgUserRepository.CreateAsync(new OrganizationUser + { + UserId = user.Id, + OrganizationId = org.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + var collection = new Collection { Id = CoreHelpers.GenerateComb(), Name = "Test Collection", OrganizationId = org.Id }; + var cipher = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, OrganizationId = org.Id, Data = "" }; + var collectionCipher = new CollectionCipher { CollectionId = collection.Id, CipherId = cipher.Id }; + var collectionUser = new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }; + + // Act + await cipherRepository.CreateAsync_vNext( + ciphers: [cipher], + collections: [collection], + collectionCiphers: [collectionCipher], + collectionUsers: [collectionUser]); + + // Assert + var orgCiphers = await cipherRepository.GetManyByOrganizationIdAsync(org.Id); + Assert.Contains(orgCiphers, c => c.Id == cipher.Id); + + var collCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(org.Id); + Assert.Contains(collCiphers, cc => cc.CipherId == cipher.Id && cc.CollectionId == collection.Id); + + var collectionsInOrg = await collectionRepository.GetManyByOrganizationIdAsync(org.Id); + Assert.Contains(collectionsInOrg, c => c.Id == collection.Id); + + var collectionUsers = await collectionRepository.GetManyUsersByIdAsync(collection.Id); + var foundCollectionUser = collectionUsers.FirstOrDefault(cu => cu.Id == orgUser.Id); + Assert.NotNull(foundCollectionUser); + Assert.True(foundCollectionUser.Manage); + Assert.False(foundCollectionUser.ReadOnly); + Assert.False(foundCollectionUser.HidePasswords); + } + + [DatabaseTheory, DatabaseData] + public async Task UpdateCiphersAsync_vNext_Works( + IUserRepository userRepository, ICipherRepository cipherRepository) + { + // Arrange + var expectedNewType = CipherType.SecureNote; + var expectedNewAttachments = "bulk_new_attachments"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var c1 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" }; + var c2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" }; + await cipherRepository.CreateAsync( + userId: user.Id, + ciphers: [c1, c2], + folders: []); + + c1.Type = expectedNewType; + c2.Attachments = expectedNewAttachments; + + // Act + await cipherRepository.UpdateCiphersAsync_vNext(user.Id, [c1, c2]); + + // Assert + var updated1 = await cipherRepository.GetByIdAsync(c1.Id); + Assert.NotNull(updated1); + Assert.Equal(c1.Id, updated1.Id); + Assert.Equal(expectedNewType, updated1.Type); + Assert.Equal(c1.UserId, updated1.UserId); + Assert.Equal(c1.Data, updated1.Data); + Assert.Equal(c1.OrganizationId, updated1.OrganizationId); + Assert.Equal(c1.Attachments, updated1.Attachments); + + var updated2 = await cipherRepository.GetByIdAsync(c2.Id); + Assert.NotNull(updated2); + Assert.Equal(c2.Id, updated2.Id); + Assert.Equal(c2.Type, updated2.Type); + Assert.Equal(c2.UserId, updated2.UserId); + Assert.Equal(c2.Data, updated2.Data); + Assert.Equal(c2.OrganizationId, updated2.OrganizationId); + Assert.Equal(expectedNewAttachments, updated2.Attachments); + } + [DatabaseTheory, DatabaseData] public async Task DeleteCipherWithSecurityTaskAsync_Works( IOrganizationRepository organizationRepository, From fa8d65cc1f572fad047e3da17eebb299fa097ef2 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 3 Sep 2025 20:33:32 +0530 Subject: [PATCH 25/85] [PM 19727] Update InvoiceUpcoming email content (#6168) * changes to implement the email * Refactoring and fix the unit testing * refactor the code and remove used method * Fix the failing test * Update the email templates * remove the extra space here * Refactor the descriptions * Fix the wrong subject header * Add the in the hyperlink rather than just Help center --- .../Implementations/UpcomingInvoiceHandler.cs | 40 +- .../Billing/Extensions/InvoiceExtensions.cs | 76 ++++ .../Handlebars/Layouts/ProviderFull.html.hbs | 211 ++++++++++ .../ProviderInvoiceUpcoming.html.hbs | 89 ++++ .../ProviderInvoiceUpcoming.text.hbs | 41 ++ .../Models/Mail/InvoiceUpcomingViewModel.cs | 5 + src/Core/Services/IMailService.cs | 8 + .../Implementations/HandlebarsMailService.cs | 42 ++ .../NoopImplementations/NoopMailService.cs | 9 + .../Extensions/InvoiceExtensionsTests.cs | 394 ++++++++++++++++++ 10 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 src/Core/Billing/Extensions/InvoiceExtensions.cs create mode 100644 src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs create mode 100644 test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 9b1d110b5e..9f6fda7d3f 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -18,6 +19,7 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class UpcomingInvoiceHandler( + IGetPaymentMethodQuery getPaymentMethodQuery, ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, @@ -137,7 +139,7 @@ public class UpcomingInvoiceHandler( await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id); - await SendUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice); + await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); } } @@ -158,6 +160,42 @@ public class UpcomingInvoiceHandler( } } + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, Subscription subscription, Guid providerId) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.FormatForProvider(subscription); + + if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) + { + var provider = await providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); + return; + } + + var collectionMethod = subscription.CollectionMethod; + var paymentMethod = await getPaymentMethodQuery.Run(provider); + + var hasPaymentMethod = paymentMethod != null; + var paymentMethodDescription = paymentMethod?.Match( + bankAccount => $"Bank account ending in {bankAccount.Last4}", + card => $"{card.Brand} ending in {card.Last4}", + payPal => $"PayPal account {payPal.Email}" + ); + + await mailService.SendProviderInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + collectionMethod, + hasPaymentMethod, + paymentMethodDescription); + } + } + private async Task AlignOrganizationTaxConcernsAsync( Organization organization, Subscription subscription, diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs new file mode 100644 index 0000000000..bb9f7588bf --- /dev/null +++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class InvoiceExtensions +{ + /// + /// Formats invoice line items specifically for provider invoices, standardizing product descriptions + /// and ensuring consistent tax representation. + /// + /// The Stripe invoice containing line items + /// The associated subscription (for future extensibility) + /// A list of formatted invoice item descriptions + public static List FormatForProvider(this Invoice invoice, Subscription subscription) + { + var items = new List(); + + // Return empty list if no line items + if (invoice.Lines == null) + { + return items; + } + + foreach (var line in invoice.Lines.Data ?? new List()) + { + // Skip null lines or lines without description + if (line?.Description == null) + { + continue; + } + + var description = line.Description; + + // Handle Provider Portal and Business Unit Portal service lines + if (description.Contains("Provider Portal") || description.Contains("Business Unit")) + { + var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)"); + var priceInfo = priceMatch.Success ? priceMatch.Value : ""; + + var standardizedDescription = $"{line.Quantity} × Manage service provider {priceInfo}"; + items.Add(standardizedDescription); + } + // Handle tax lines + else if (description.ToLower().Contains("tax")) + { + var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)"); + var priceInfo = priceMatch.Success ? priceMatch.Value : ""; + + // If no price info found in description, calculate from amount + if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0) + { + var pricePerItem = (line.Amount / 100m) / line.Quantity; + priceInfo = $"(at ${pricePerItem:F2} / month)"; + } + + var taxDescription = $"{line.Quantity} × Tax {priceInfo}"; + items.Add(taxDescription); + } + // Handle other line items as-is + else + { + items.Add(description); + } + } + + // Add fallback tax from invoice-level tax if present and not already included + if (invoice.Tax.HasValue && invoice.Tax.Value > 0) + { + var taxAmount = invoice.Tax.Value / 100m; + items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); + } + + return items; + } +} diff --git a/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs new file mode 100644 index 0000000000..33e32c2bb0 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs @@ -0,0 +1,211 @@ + + + + + + Bitwarden + + + + + {{! Yahoo center fix }} + + + + +
+ {{! 600px container }} + + + {{! Left column (center fix) }} + + {{! Right column (center fix) }} + +
+ + + + + +
+ Bitwarden +
+ + + + + + +
+ + {{>@partial-block}} + +
+ + + + + + + + + + + +
+
+ + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs new file mode 100644 index 0000000000..d9061d1ffe --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs @@ -0,0 +1,89 @@ +{{#>ProviderFull}} + + + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} + {{#if Items}} + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} + {{/if}} + + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + + + {{/unless}} + + + + {{#if (eq CollectionMethod "send_invoice")}} + + + + {{/if}} + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} +
+ {{#if (eq CollectionMethod "send_invoice")}} +
Your subscription will renew soon
+
On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax.
+ {{else}} +
Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}
+ {{#if HasPaymentMethod}} +
To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:
+ {{else}} +
To avoid any interruption in service, please add a payment method that can be charged for the following amount:
+ {{/if}} + {{/if}} +
+ {{usd AmountDue}} +
+ Summary Of Charges
+
+ {{#each Items}} +
{{this}}
+ {{/each}} +
+ {{#if (eq CollectionMethod "send_invoice")}} +
To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.
+ {{else}} + + {{/if}} +
+ + + + +
+ Update payment method +
+
+ {{#if (eq CollectionMethod "send_invoice")}} + + + + +
+ Contact Bitwarden Support +
+ {{/if}} +
+ For assistance managing your subscription, please visit the Help Center or contact Bitwarden Customer Support. +
+ For assistance managing your subscription, please visit the Help Center or contact Bitwarden Customer Support. +
+{{/ProviderFull}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs new file mode 100644 index 0000000000..c666e287a5 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs @@ -0,0 +1,41 @@ +{{#>BasicTextLayout}} +{{#if (eq CollectionMethod "send_invoice")}} +Your subscription will renew soon + +On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax. +{{else}} +Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}} + + {{#if HasPaymentMethod}} +To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount: + {{else}} +To avoid any interruption in service, please add a payment method that can be charged for the following amount: + {{/if}} + +{{usd AmountDue}} +{{/if}} +{{#if Items}} +{{#unless (eq CollectionMethod "send_invoice")}} + +Summary Of Charges +------------------ +{{#each Items}} +{{this}} +{{/each}} +{{/unless}} +{{/if}} + +{{#if (eq CollectionMethod "send_invoice")}} +To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay. + +Contact Bitwarden Support: {{{ContactUrl}}} + +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{else}} + +{{/if}} + +{{#unless (eq CollectionMethod "send_invoice")}} +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{/unless}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs b/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs index 50f8256b3d..b63213b811 100644 --- a/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs +++ b/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs @@ -10,4 +10,9 @@ public class InvoiceUpcomingViewModel : BaseMailModel public List Items { get; set; } public bool MentionInvoices { get; set; } public string UpdateBillingInfoUrl { get; set; } = "https://bitwarden.com/help/update-billing-info/"; + public string CollectionMethod { get; set; } + public bool HasPaymentMethod { get; set; } + public string PaymentMethodDescription { get; set; } + public string HelpUrl { get; set; } = "https://bitwarden.com/help/"; + public string ContactUrl { get; set; } = "https://bitwarden.com/contact/"; } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index a38328dc9d..6e61c4f8dd 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -59,6 +59,14 @@ public interface IMailService DateTime dueDate, List items, bool mentionInvoices); + Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod, + bool hasPaymentMethod, + string? paymentMethodDescription); Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices); Task SendAddedCreditAsync(string email, decimal amount); Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 8de0e99bd3..0410bad19e 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -478,6 +478,33 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod = null, + bool hasPaymentMethod = true, + string? paymentMethodDescription = null) + { + var message = CreateDefaultMessage("Your upcoming Bitwarden invoice", emails); + var model = new InvoiceUpcomingViewModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + AmountDue = amount, + DueDate = dueDate, + Items = items, + MentionInvoices = false, + CollectionMethod = collectionMethod, + HasPaymentMethod = hasPaymentMethod, + PaymentMethodDescription = paymentMethodDescription + }; + await AddMessageContentAsync(message, "ProviderInvoiceUpcoming", model); + message.Category = "ProviderInvoiceUpcoming"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { var message = CreateDefaultMessage("Payment Failed", email); @@ -708,6 +735,8 @@ public class HandlebarsMailService : IMailService Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource); var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text"); Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource); + var providerFullHtmlLayoutSource = await ReadSourceAsync("Layouts.ProviderFull.html"); + Handlebars.RegisterTemplate("ProviderFull", providerFullHtmlLayoutSource); Handlebars.RegisterHelper("date", (writer, context, parameters) => { @@ -863,6 +892,19 @@ public class HandlebarsMailService : IMailService writer.WriteSafeString(string.Empty); } }); + + // Equality comparison helper for conditional templates. + Handlebars.RegisterHelper("eq", (context, arguments) => + { + if (arguments.Length != 2) + { + return false; + } + + var value1 = arguments[0]?.ToString(); + var value2 = arguments[1]?.ToString(); + return string.Equals(value1, value2, StringComparison.OrdinalIgnoreCase); + }); } public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index bc73fb5398..7ec05bb1f9 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -137,6 +137,15 @@ public class NoopMailService : IMailService List items, bool mentionInvoices) => Task.FromResult(0); + public Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod = null, + bool hasPaymentMethod = true, + string? paymentMethodDescription = null) => Task.FromResult(0); + public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { return Task.FromResult(0); diff --git a/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs new file mode 100644 index 0000000000..a30e5e896c --- /dev/null +++ b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs @@ -0,0 +1,394 @@ +using Bit.Core.Billing.Extensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Extensions; + +public class InvoiceExtensionsTests +{ + private static Invoice CreateInvoiceWithLines(params InvoiceLineItem[] lineItems) + { + return new Invoice + { + Lines = new StripeList + { + Data = lineItems?.ToList() ?? new List() + } + }; + } + + #region FormatForProvider Tests + + [Fact] + public void FormatForProvider_NullLines_ReturnsEmptyList() + { + // Arrange + var invoice = new Invoice + { + Lines = null + }; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_EmptyLines_ReturnsEmptyList() + { + // Arrange + var invoice = CreateInvoiceWithLines(); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_NullLineItem_SkipsNullLine() + { + // Arrange + var invoice = CreateInvoiceWithLines(null); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_LineWithNullDescription_SkipsLine() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem { Description = null, Quantity = 1, Amount = 1000 } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_ProviderPortalTeams_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams (at $6.00 / month)", + Quantity = 5, + Amount = 3000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_ProviderPortalEnterprise_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Enterprise (at $4.00 / month)", + Quantity = 10, + Amount = 4000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_ProviderPortalWithoutPriceInfo_FormatsWithoutPrice() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 3, + Amount = 1800 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("3 × Manage service provider ", result[0]); + } + + [Fact] + public void FormatForProvider_BusinessUnitPortalEnterprise_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Business Unit Portal - Enterprise (at $5.00 / month)", + Quantity = 8, + Amount = 4000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("8 × Manage service provider (at $5.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_BusinessUnitPortalGeneric_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Business Unit Portal (at $3.00 / month)", + Quantity = 2, + Amount = 600 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("2 × Manage service provider (at $3.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_TaxLineWithPriceInfo_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Tax (at $2.00 / month)", + Quantity = 1, + Amount = 200 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("1 × Tax (at $2.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_TaxLineWithoutPriceInfo_CalculatesPrice() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Tax", + Quantity = 2, + Amount = 400 // $4.00 total, $2.00 per item + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("2 × Tax (at $2.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_TaxLineWithZeroQuantity_DoesNotCalculatePrice() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Tax", + Quantity = 0, + Amount = 200 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("0 × Tax ", result[0]); + } + + [Fact] + public void FormatForProvider_OtherLineItem_ReturnsAsIs() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Some other service", + Quantity = 1, + Amount = 1000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("Some other service", result[0]); + } + + [Fact] + public void FormatForProvider_InvoiceLevelTax_AddsToResult() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 1, + Amount = 600 + } + ); + invoice.Tax = 120; // $1.20 in cents + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("1 × Manage service provider ", result[0]); + Assert.Equal("1 × Tax (at $1.20 / month)", result[1]); + } + + [Fact] + public void FormatForProvider_NoInvoiceLevelTax_DoesNotAddTax() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 1, + Amount = 600 + } + ); + invoice.Tax = null; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("1 × Manage service provider ", result[0]); + } + + [Fact] + public void FormatForProvider_ZeroInvoiceLevelTax_DoesNotAddTax() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 1, + Amount = 600 + } + ); + invoice.Tax = 0; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("1 × Manage service provider ", result[0]); + } + + [Fact] + public void FormatForProvider_ComplexScenario_HandlesAllLineTypes() + { + // Arrange + var lineItems = new StripeList(); + lineItems.Data = new List + { + new InvoiceLineItem + { + Description = "Provider Portal - Teams (at $6.00 / month)", Quantity = 5, Amount = 3000 + }, + new InvoiceLineItem + { + Description = "Provider Portal - Enterprise (at $4.00 / month)", Quantity = 10, Amount = 4000 + }, + new InvoiceLineItem { Description = "Tax", Quantity = 1, Amount = 800 }, + new InvoiceLineItem { Description = "Custom Service", Quantity = 2, Amount = 2000 } + }; + + var invoice = new Invoice + { + Lines = lineItems, + Tax = 200 // Additional $2.00 tax + }; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]); + Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]); + Assert.Equal("1 × Tax (at $8.00 / month)", result[2]); + Assert.Equal("Custom Service", result[3]); + Assert.Equal("1 × Tax (at $2.00 / month)", result[4]); + } + + #endregion +} From ef8c7f656d87a7b0e3307489f92c571719d01012 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:03:49 -0500 Subject: [PATCH 26/85] [PM-24350] fix tax calculation (#6251) --- .../Services/ProviderBillingService.cs | 5 +- .../OrganizationCreateRequestModel.cs | 3 +- .../Request/Accounts/PremiumRequestModel.cs | 3 +- .../Accounts/TaxInfoUpdateRequestModel.cs | 3 +- .../Implementations/StripeEventService.cs | 2 +- .../Implementations/UpcomingInvoiceHandler.cs | 4 +- .../Billing/Extensions/BillingExtensions.cs | 13 + .../Extensions/ServiceCollectionExtensions.cs | 3 - .../Services/OrganizationBillingService.cs | 6 +- .../Commands/UpdateBillingAddressCommand.cs | 2 +- .../Implementations/SubscriberService.cs | 10 +- .../Tax/Commands/PreviewTaxAmountCommand.cs | 14 +- .../Tax/Services/IAutomaticTaxFactory.cs | 11 - .../Tax/Services/IAutomaticTaxStrategy.cs | 33 -- .../Implementations/AutomaticTaxFactory.cs | 50 -- .../BusinessUseAutomaticTaxStrategy.cs | 96 ---- .../PersonalUseAutomaticTaxStrategy.cs | 64 --- src/Core/Constants.cs | 13 + src/Core/Models/Business/TaxInfo.cs | 2 +- .../Implementations/StripePaymentService.cs | 24 +- .../Commands/PreviewTaxAmountCommandTests.cs | 267 +++++++++- .../Tax/Services/AutomaticTaxFactoryTests.cs | 105 ---- .../BusinessUseAutomaticTaxStrategyTests.cs | 492 ------------------ .../Tax/Services/FakeAutomaticTaxStrategy.cs | 35 -- .../PersonalUseAutomaticTaxStrategyTests.cs | 217 -------- .../Services/StripePaymentServiceTests.cs | 358 ++++++++++++- 26 files changed, 663 insertions(+), 1172 deletions(-) delete mode 100644 src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs delete mode 100644 src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs delete mode 100644 src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs delete mode 100644 src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs delete mode 100644 src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 8c0b2c8275..5169d6cfd1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -3,6 +3,7 @@ using System.Globalization; using Bit.Commercial.Core.Billing.Providers.Models; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -282,7 +283,7 @@ public class ProviderBillingService( ] }; - if (providerCustomer.Address is not { Country: "US" }) + if (providerCustomer.Address is not { Country: Constants.CountryAbbreviations.UnitedStates }) { customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; } @@ -525,7 +526,7 @@ public class ProviderBillingService( } }; - if (taxInfo.BillingAddressCountry is not "US") + if (taxInfo.BillingAddressCountry is not Constants.CountryAbbreviations.UnitedStates) { options.TaxExempt = StripeConstants.TaxExempt.Reverse; } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 10f938adfe..7754c44c8c 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Bit.Core; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -139,7 +140,7 @@ public class OrganizationCreateRequestModel : IValidatableObject new string[] { nameof(BillingAddressCountry) }); } - if (PlanType != PlanType.Free && BillingAddressCountry == "US" && + if (PlanType != PlanType.Free && BillingAddressCountry == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(BillingAddressPostalCode)) { yield return new ValidationResult("Zip / postal code is required.", diff --git a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs index 4e9882d67c..8e9aac8cc2 100644 --- a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs +++ b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core; using Bit.Core.Settings; using Enums = Bit.Core.Enums; @@ -35,7 +36,7 @@ public class PremiumRequestModel : IValidatableObject { yield return new ValidationResult("Payment token or license is required."); } - if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode)) { yield return new ValidationResult("Zip / postal code is required.", new string[] { nameof(PostalCode) }); diff --git a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs index 5f58453a6d..d3e3f5ec55 100644 --- a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs +++ b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core; namespace Bit.Api.Models.Request.Accounts; @@ -13,7 +14,7 @@ public class TaxInfoUpdateRequestModel : IValidatableObject public virtual IEnumerable Validate(ValidationContext validationContext) { - if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode)) { yield return new ValidationResult("Zip / postal code is required.", new string[] { nameof(PostalCode) }); diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 7e2984e423..7eef357e14 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -218,7 +218,7 @@ public class StripeEventService : IStripeEventService private static string GetCustomerRegion(IDictionary customerMetadata) { - const string defaultRegion = "US"; + const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; if (customerMetadata is null) { diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 9f6fda7d3f..e5675f7c0a 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -203,7 +203,7 @@ public class UpcomingInvoiceHandler( { var nonUSBusinessUse = organization.PlanType.GetProductTier() != ProductTierType.Families && - subscription.Customer.Address.Country != "US"; + subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { @@ -248,7 +248,7 @@ public class UpcomingInvoiceHandler( Subscription subscription, string eventId) { - if (subscription.Customer.Address.Country != "US" && + if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { try diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 55db9dde18..7f81bfd33f 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -22,6 +22,19 @@ public static class BillingExtensions _ => throw new BillingException($"PlanType {planType} could not be matched to a ProductTierType") }; + public static bool IsBusinessProductTierType(this PlanType planType) + => IsBusinessProductTierType(planType.GetProductTier()); + + public static bool IsBusinessProductTierType(this ProductTierType productTierType) + => productTierType switch + { + ProductTierType.Free => false, + ProductTierType.Families => false, + ProductTierType.Enterprise => true, + ProductTierType.Teams => true, + ProductTierType.TeamsStarter => true + }; + public static bool IsBillable(this Provider provider) => provider is { diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 39ee3ec1ec..147e96105a 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -25,9 +25,6 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddKeyedTransient(AutomaticTaxFactory.PersonalUse); - services.AddKeyedTransient(AutomaticTaxFactory.BusinessUse); - services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); services.AddTransient(); diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 0e42803aaf..446f9563f9 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -275,7 +275,7 @@ public class OrganizationBillingService( if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families && - customerSetup.TaxInformation.Country != "US") + customerSetup.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) { customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; } @@ -514,14 +514,14 @@ public class OrganizationBillingService( customer = customer switch { - { Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await + { Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, TaxExempt = StripeConstants.TaxExempt.Reverse }), - { Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await + { Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs index fdf519523a..f4eca40cae 100644 --- a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -84,7 +84,7 @@ public class UpdateBillingAddressCommand( State = billingAddress.State }, Expand = ["subscriptions", "tax_ids"], - TaxExempt = billingAddress.Country != "US" + TaxExempt = billingAddress.Country != Core.Constants.CountryAbbreviations.UnitedStates ? StripeConstants.TaxExempt.Reverse : StripeConstants.TaxExempt.None }); diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 63a9352020..84d41f829c 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -801,15 +801,13 @@ public class SubscriberService( _ => false }; - - if (isBusinessUseSubscriber) { switch (customer) { case { - Address.Country: not "US", + Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not TaxExempt.Reverse }: await stripeAdapter.CustomerUpdateAsync(customer.Id, @@ -817,7 +815,7 @@ public class SubscriberService( break; case { - Address.Country: "US", + Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: TaxExempt.Reverse }: await stripeAdapter.CustomerUpdateAsync(customer.Id, @@ -840,8 +838,8 @@ public class SubscriberService( { User => true, Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families || - customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), - Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false), + Provider => customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false), _ => false }; diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs index 6e061293c7..94d3724d73 100644 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -95,17 +95,11 @@ public class PreviewTaxAmountCommand( } } - if (planType.GetProductTier() == ProductTierType.Families) + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + if (parameters.PlanType.IsBusinessProductTierType() && + parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - else - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = options.CustomerDetails.Address.Country == "US" || - options.CustomerDetails.TaxIds is [_, ..] - }; + options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; } var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs deleted file mode 100644 index c0a31efb3c..0000000000 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Core.Billing.Tax.Services; - -/// -/// Responsible for defining the correct automatic tax strategy for either personal use of business use. -/// -public interface IAutomaticTaxFactory -{ - Task CreateAsync(AutomaticTaxFactoryParameters parameters); -} diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs deleted file mode 100644 index 557bb1d30c..0000000000 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs +++ /dev/null @@ -1,33 +0,0 @@ -#nullable enable -using Stripe; - -namespace Bit.Core.Billing.Tax.Services; - -public interface IAutomaticTaxStrategy -{ - /// - /// - /// - /// - /// - /// Returns if changes are to be applied to the subscription, returns null - /// otherwise. - /// - SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription); - - /// - /// Modifies an existing object with the automatic tax flag set correctly. - /// - /// - /// - void SetCreateOptions(SubscriptionCreateOptions options, Customer customer); - - /// - /// Modifies an existing object with the automatic tax flag set correctly. - /// - /// - /// - void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription); - - void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options); -} diff --git a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs deleted file mode 100644 index 6086a16b79..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs +++ /dev/null @@ -1,50 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; -using Bit.Core.Entities; -using Bit.Core.Services; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class AutomaticTaxFactory( - IFeatureService featureService, - IPricingClient pricingClient) : IAutomaticTaxFactory -{ - public const string BusinessUse = "business-use"; - public const string PersonalUse = "personal-use"; - - private readonly Lazy>> _personalUsePlansTask = new(async () => - { - var plans = await Task.WhenAll( - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually)); - - return plans.Select(plan => plan.PasswordManager.StripePlanId); - }); - - public async Task CreateAsync(AutomaticTaxFactoryParameters parameters) - { - if (parameters.Subscriber is User) - { - return new PersonalUseAutomaticTaxStrategy(featureService); - } - - if (parameters.PlanType.HasValue) - { - var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value); - return plan.CanBeUsedByBusiness - ? new BusinessUseAutomaticTaxStrategy(featureService) - : new PersonalUseAutomaticTaxStrategy(featureService); - } - - var personalUsePlans = await _personalUsePlansTask.Value; - - if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x))) - { - return new PersonalUseAutomaticTaxStrategy(featureService); - } - - return new BusinessUseAutomaticTaxStrategy(featureService); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs deleted file mode 100644 index 6affc57354..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs +++ /dev/null @@ -1,96 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Extensions; -using Bit.Core.Services; -using Stripe; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy -{ - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return null; - } - - var shouldBeEnabled = ShouldBeEnabled(subscription.Customer); - if (subscription.AutomaticTax.Enabled == shouldBeEnabled) - { - return null; - } - - var options = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = shouldBeEnabled - }, - DefaultTaxRates = [] - }; - - return options; - } - - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(customer) - }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return; - } - - var shouldBeEnabled = ShouldBeEnabled(subscription.Customer); - - if (subscription.AutomaticTax.Enabled == shouldBeEnabled) - { - return; - } - - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = shouldBeEnabled - }; - options.DefaultTaxRates = []; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax ??= new InvoiceAutomaticTaxOptions(); - - if (options.CustomerDetails.Address.Country == "US") - { - options.AutomaticTax.Enabled = true; - return; - } - - options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any(); - } - - private bool ShouldBeEnabled(Customer customer) - { - if (!customer.HasRecognizedTaxLocation()) - { - return false; - } - - if (customer.Address.Country == "US") - { - return true; - } - - if (customer.TaxIds == null) - { - throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded."); - } - - return customer.TaxIds.Any(); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs deleted file mode 100644 index 615222259e..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs +++ /dev/null @@ -1,64 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Extensions; -using Bit.Core.Services; -using Stripe; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy -{ - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(customer) - }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return; - } - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(subscription.Customer) - }; - options.DefaultTaxRates = []; - } - - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return null; - } - - if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer)) - { - return null; - } - - var options = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(subscription.Customer), - }, - DefaultTaxRates = [] - }; - - return options; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - - private static bool ShouldBeEnabled(Customer customer) - { - return customer.HasRecognizedTaxLocation(); - } -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2993f6a094..9ddbf5c600 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -52,6 +52,19 @@ public static class Constants /// regardless of whether there is a proration or not. ///
public const string AlwaysInvoice = "always_invoice"; + + /// + /// Used primarily to determine whether a customer's business is inside or outside the United States + /// for billing purposes. + /// + public static class CountryAbbreviations + { + /// + /// Abbreviation for The United States. + /// This value must match what Stripe uses for the `Country` field value for the United States. + /// + public const string UnitedStates = "US"; + } } public static class AuthConstants diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs index 4daa9a268a..4f95bb393d 100644 --- a/src/Core/Models/Business/TaxInfo.cs +++ b/src/Core/Models/Business/TaxInfo.cs @@ -13,5 +13,5 @@ public class TaxInfo public string BillingAddressCity { get; set; } public string BillingAddressState { get; set; } public string BillingAddressPostalCode { get; set; } - public string BillingAddressCountry { get; set; } = "US"; + public string BillingAddressCountry { get; set; } = Constants.CountryAbbreviations.UnitedStates; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 440fb5c546..ec45944bd2 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -9,11 +9,9 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Services; -using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -21,7 +19,6 @@ using Bit.Core.Models.BitStripe; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; using PaymentMethod = Stripe.PaymentMethod; @@ -41,8 +38,6 @@ public class StripePaymentService : IPaymentService private readonly IFeatureService _featureService; private readonly ITaxService _taxService; private readonly IPricingClient _pricingClient; - private readonly IAutomaticTaxFactory _automaticTaxFactory; - private readonly IAutomaticTaxStrategy _personalUseTaxStrategy; public StripePaymentService( ITransactionRepository transactionRepository, @@ -52,9 +47,7 @@ public class StripePaymentService : IPaymentService IGlobalSettings globalSettings, IFeatureService featureService, ITaxService taxService, - IPricingClient pricingClient, - IAutomaticTaxFactory automaticTaxFactory, - [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy) + IPricingClient pricingClient) { _transactionRepository = transactionRepository; _logger = logger; @@ -64,8 +57,6 @@ public class StripePaymentService : IPaymentService _featureService = featureService; _taxService = taxService; _pricingClient = pricingClient; - _automaticTaxFactory = automaticTaxFactory; - _personalUseTaxStrategy = personalUseTaxStrategy; } private async Task ChangeOrganizationSponsorship( @@ -137,7 +128,7 @@ public class StripePaymentService : IPaymentService { if (sub.Customer is { - Address.Country: not "US", + Address.Country: not Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse }) { @@ -987,8 +978,6 @@ public class StripePaymentService : IPaymentService } } - _personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options); - try { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); @@ -1152,9 +1141,12 @@ public class StripePaymentService : IPaymentService } } - var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan); - var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters); - automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options); + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + if (parameters.PasswordManager.Plan.IsBusinessProductTierType() && + parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates) + { + options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; + } try { diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs index ee5625d522..1de180cea1 100644 --- a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs +++ b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs @@ -181,7 +181,7 @@ public class PreviewTaxAmountCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == false + options.AutomaticTax.Enabled == true )) .Returns(expectedInvoice); @@ -273,4 +273,269 @@ public class PreviewTaxAmountCommandTests var badRequest = result.AsT1; Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response); } + + [Fact] + public async Task Run_USBased_PersonalUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_USBased_BusinessUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_USBased_PersonalUse_DoesNotSetTaxExempt() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_USBased_BusinessUse_DoesNotSetTaxExempt() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_NonUSBased_PersonalUse_DoesNotSetTaxExempt() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + Assert.True(result.IsT0); + + } + + [Fact] + public async Task Run_NonUSBased_BusinessUse_SetsTaxExemptReverse() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse + )); + Assert.True(result.IsT0); + } } diff --git a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs deleted file mode 100644 index d9d2679bca..0000000000 --- a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models.StaticStore.Plans; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services.Implementations; -using Bit.Core.Entities; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Billing.Tax.Services; - -[SutProviderCustomize] -public class AutomaticTaxFactoryTests -{ - [BitAutoData] - [Theory] - public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsUser(SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(new User(), []); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [BitAutoData] - [Theory] - public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsOrganizationWithFamiliesAnnuallyPrice( - SutProvider sut) - { - var familiesPlan = new FamiliesPlan(); - var parameters = new AutomaticTaxFactoryParameters(new Organization(), [familiesPlan.PasswordManager.StripePlanId]); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) - .Returns(new FamiliesPlan()); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually2019)) - .Returns(new Families2019Plan()); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [Theory] - [BitAutoData] - public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenSubscriberIsOrganizationWithBusinessUsePrice( - EnterpriseAnnually plan, - SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(new Organization(), [plan.PasswordManager.StripePlanId]); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) - .Returns(new FamiliesPlan()); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually2019)) - .Returns(new Families2019Plan()); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [Theory] - [BitAutoData] - public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenPlanIsMeantForPersonalUse(SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(PlanType.FamiliesAnnually); - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == parameters.PlanType.Value)) - .Returns(new FamiliesPlan()); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [Theory] - [BitAutoData] - public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenPlanIsMeantForBusinessUse(SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(PlanType.EnterpriseAnnually); - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == parameters.PlanType.Value)) - .Returns(new EnterprisePlan(true)); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - public record EnterpriseAnnually : EnterprisePlan - { - public EnterpriseAnnually() : base(true) - { - } - } -} diff --git a/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs deleted file mode 100644 index dc10d222f1..0000000000 --- a/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs +++ /dev/null @@ -1,492 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Tax.Services.Implementations; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Tax.Services; - -[SutProviderCustomize] -public class BusinessUseAutomaticTaxStrategyTests -{ - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled( - SutProvider sutProvider) - { - var subscription = new Subscription(); - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(false); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.False(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List - { - new() - { - Country = "ES", - Type = "eu_vat", - Value = "ESZ8880999Z" - } - } - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = null - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - Assert.Throws(() => sutProvider.Sut.GetUpdateOptions(subscription)); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds( - SutProvider sutProvider) - { - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List() - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.False(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsNothing_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - Customer = new Customer - { - Address = new() - { - Country = "US" - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(false); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.Null(options.AutomaticTax); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsNothing_WhenSubscriptionDoesNotNeedUpdating( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.Null(options.AutomaticTax); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.False(options.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.True(options.AutomaticTax!.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List - { - new() - { - Country = "ES", - Type = "eu_vat", - Value = "ESZ8880999Z" - } - } - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.True(options.AutomaticTax!.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = null - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - Assert.Throws(() => sutProvider.Sut.SetUpdateOptions(options, subscription)); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List() - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.False(options.AutomaticTax!.Enabled); - } -} diff --git a/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs b/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs deleted file mode 100644 index 2f3cbc98ee..0000000000 --- a/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Bit.Core.Billing.Tax.Services; -using Stripe; - -namespace Bit.Core.Test.Billing.Tax.Services; - -/// -/// Whether the subscription options will have automatic tax enabled or not. -/// -public class FakeAutomaticTaxStrategy( - bool isAutomaticTaxEnabled) : IAutomaticTaxStrategy -{ - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - return new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled } - }; - } - - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }; - - } -} diff --git a/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs deleted file mode 100644 index 30614b94ba..0000000000 --- a/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Tax.Services.Implementations; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Tax.Services; - -[SutProviderCustomize] -public class PersonalUseAutomaticTaxStrategyTests -{ - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled( - SutProvider sutProvider) - { - var subscription = new Subscription(); - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(false); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.False(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData("CA")] - [BitAutoData("ES")] - [BitAutoData("US")] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAllCountries( - string country, SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = country - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData("CA")] - [BitAutoData("ES")] - [BitAutoData("US")] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds( - string country, SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = country, - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List - { - new() - { - Country = "ES", - Type = "eu_vat", - Value = "ESZ8880999Z" - } - } - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData("CA")] - [BitAutoData("ES")] - [BitAutoData("US")] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds( - string country, SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = country - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List() - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } -} diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 7d8a059d76..609437b8d1 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -1,12 +1,10 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Services; -using Bit.Core.Test.Billing.Tax.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -23,10 +21,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -74,10 +68,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -125,10 +115,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -177,10 +163,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -223,4 +205,340 @@ public class StripePaymentServiceTests Assert.Equal(4.08M, actual.TotalAmount); Assert.Equal(4M, actual.TaxableBaseAmount); } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_PersonalUse_DoesNotSetTaxExempt(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_BusinessUse_DoesNotSetTaxExempt(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_DoesNotSetTaxExempt(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsTaxExemptReverse(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse + )); + } } From 3731c7c40c3e7da515328318e2b64983cd96d4f6 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 3 Sep 2025 10:39:12 -0500 Subject: [PATCH 27/85] PM-24436 Add logging to backend for Member Access Report (#6159) * pm-24436 inital commit * PM-24436 updating logsto bypass event filter --- src/Api/Dirt/Controllers/ReportsController.cs | 32 ++++++++----------- .../ReportFeatures/MemberAccessReportQuery.cs | 21 +++++++++++- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index e7c7e4a9bf..d643d68661 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -1,6 +1,7 @@ using Bit.Api.Dirt.Models; using Bit.Api.Dirt.Models.Response; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Reports.Models.Data; @@ -26,6 +27,7 @@ public class ReportsController : Controller private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand; private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly ILogger _logger; public ReportsController( ICurrentContext currentContext, @@ -36,7 +38,8 @@ public class ReportsController : Controller IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, IGetOrganizationReportQuery getOrganizationReportQuery, IAddOrganizationReportCommand addOrganizationReportCommand, - IDropOrganizationReportCommand dropOrganizationReportCommand + IDropOrganizationReportCommand dropOrganizationReportCommand, + ILogger logger ) { _currentContext = currentContext; @@ -48,6 +51,7 @@ public class ReportsController : Controller _getOrganizationReportQuery = getOrganizationReportQuery; _addOrganizationReportCommand = addOrganizationReportCommand; _dropOrganizationReportCommand = dropOrganizationReportCommand; + _logger = logger; } /// @@ -86,32 +90,24 @@ public class ReportsController : Controller { if (!await _currentContext.AccessReports(orgId)) { + _logger.LogInformation(Constants.BypassFiltersEventId, + "AccessReports Check - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}", + _currentContext.UserId, orgId, _currentContext.DeviceType); throw new NotFoundException(); } - var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId }); + _logger.LogInformation(Constants.BypassFiltersEventId, + "MemberAccessReportQuery starts - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}", + _currentContext.UserId, orgId, _currentContext.DeviceType); + + var accessDetails = await _memberAccessReportQuery + .GetMemberAccessReportsAsync(new MemberAccessReportRequest { OrganizationId = orgId }); var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x)); return responses; } - /// - /// Contains the organization member info, the cipher ids associated with the member, - /// and details on their collections, groups, and permissions - /// - /// Request parameters - /// - /// List of a user's permissions at a group and collection level as well as the number of ciphers - /// associated with that group/collection - /// - private async Task> GetMemberAccessDetails( - MemberAccessReportRequest request) - { - var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request); - return accessDetails; - } - /// /// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids /// diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs index 33acd73d14..83d074454d 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs @@ -7,25 +7,40 @@ using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Repositories; using Bit.Core.Services; +using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.ReportFeatures; public class MemberAccessReportQuery( IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IApplicationCacheService applicationCacheService) : IMemberAccessReportQuery + IApplicationCacheService applicationCacheService, + ILogger logger) : IMemberAccessReportQuery { public async Task> GetMemberAccessReportsAsync( MemberAccessReportRequest request) { + logger.LogInformation(Constants.BypassFiltersEventId, "Starting MemberAccessReport generation for OrganizationId: {OrganizationId}", request.OrganizationId); + var baseDetails = await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId( request.OrganizationId); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved {BaseDetailsCount} base details for OrganizationId: {OrganizationId}", + baseDetails.Count(), request.OrganizationId); + var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct(); + var orgUsersCount = orgUsers.Count(); + logger.LogInformation(Constants.BypassFiltersEventId, "Found {UniqueUsersCount} unique users for OrganizationId: {OrganizationId}", + orgUsersCount, request.OrganizationId); + var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved two-factor status for {UsersCount} users for OrganizationId: {OrganizationId}", + orgUsersTwoFactorEnabled.Count(), request.OrganizationId); var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved organization ability (UseResetPassword: {UseResetPassword}) for OrganizationId: {OrganizationId}", + orgAbility?.UseResetPassword, request.OrganizationId); var accessDetails = baseDetails .GroupBy(b => new @@ -62,6 +77,10 @@ public class MemberAccessReportQuery( CipherIds = g.Select(c => c.CipherId) }); + var accessDetailsCount = accessDetails.Count(); + logger.LogInformation(Constants.BypassFiltersEventId, "Completed MemberAccessReport generation for OrganizationId: {OrganizationId}. Generated {AccessDetailsCount} access detail records", + request.OrganizationId, accessDetailsCount); + return accessDetails; } } From 93f4666df4b23e7292f456d656187c6dd587da06 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:42:19 -0500 Subject: [PATCH 28/85] [PM-25419] Move `ProviderPriceAdapter` to Core project (#6278) * Move ProviderPriceAdapter to Core * Run dotnet format --- .../Providers/Services/BusinessUnitConverterTests.cs | 1 + .../Providers/Services/ProviderBillingServiceTests.cs | 1 + .../Providers/Services/ProviderPriceAdapterTests.cs | 4 ++-- src/Api/Billing/Controllers/ProviderBillingController.cs | 1 - .../Billing/Providers/Services/ProviderPriceAdapter.cs | 8 +++----- .../Billing/Controllers/ProviderBillingControllerTests.cs | 1 - 6 files changed, 7 insertions(+), 9 deletions(-) rename {bitwarden_license/src/Commercial.Core => src/Core}/Billing/Providers/Services/ProviderPriceAdapter.cs (95%) diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index c27d990213..ec52650097 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 2bb4c9dcca..4e811017f9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs index 3087d5761c..8dcf7f6bbc 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs @@ -1,7 +1,7 @@ -using Bit.Commercial.Core.Billing.Providers.Services; -using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Services; using Stripe; using Xunit; diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index c131ed7688..f7d0593812 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -3,7 +3,6 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Models; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs b/src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs similarity index 95% rename from bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs rename to src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs index 8c55d31f2c..1346afe914 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs +++ b/src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs @@ -1,12 +1,10 @@ // ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault -#nullable enable using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.Billing; using Bit.Core.Billing.Enums; using Stripe; -namespace Bit.Commercial.Core.Billing.Providers.Services; +namespace Bit.Core.Billing.Providers.Services; public static class ProviderPriceAdapter { @@ -52,7 +50,7 @@ public static class ProviderPriceAdapter /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. /// Thrown when the provider's type is not or . - /// Thrown when the provided does not relate to a Stripe price ID. + /// Thrown when the provided does not relate to a Stripe price ID. public static string GetPriceId( Provider provider, Subscription subscription, @@ -104,7 +102,7 @@ public static class ProviderPriceAdapter /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. /// Thrown when the provider's type is not or . - /// Thrown when the provided does not relate to a Stripe price ID. + /// Thrown when the provided does not relate to a Stripe price ID. public static string GetActivePriceId( Provider provider, PlanType planType) diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 75f301ec9c..8c1dd60fb9 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -1,7 +1,6 @@ using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; From 0385347a3a78fe65dff950f1ec77a9a7f8cda38d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 3 Sep 2025 15:27:01 -0400 Subject: [PATCH 29/85] refactor: remove feature-flag (#6252) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9ddbf5c600..058f4eac69 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -160,7 +160,6 @@ public static class FeatureFlagKeys public const string TrialPayment = "PM-8163-trial-payment"; public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; - public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; From 4b79b98b316a4af6d292e5c9de05dee3cf4f231a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:47:56 +0200 Subject: [PATCH 30/85] [deps]: Update actions/create-github-app-token action to v2 (#6216) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/repository-management.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 18192ca0ad..ad80d5864c 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -82,7 +82,7 @@ jobs: version: ${{ inputs.version_number_override }} - name: Generate GH App token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} @@ -200,7 +200,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} From cdf1d7f074e3c1e4f60b87e2eb75c841aedd28d4 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:05:11 -0600 Subject: [PATCH 31/85] Add stub for load test work (#6277) * Add stub for load test work * Satisfy linter * Adding required permission for linting --- .github/workflows/load-test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/load-test.yml diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 0000000000..19aab89be3 --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,13 @@ +name: Test Stub +on: + workflow_dispatch: + +jobs: + test: + permissions: + contents: read + name: Test + runs-on: ubuntu-24.04 + steps: + - name: Test + run: exit 0 From 96fe09af893006195e6ecc50e7f6c8b786dea25d Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:08:03 -0400 Subject: [PATCH 32/85] [PM-25415] move files into better place for code ownership (#6275) * chore: move files into better place for code ownership * fix: import correct namespace --- .../SecretsManager/Commands/Projects/CreateProjectCommand.cs | 2 +- .../src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs | 3 ++- .../Authorization/OrganizationClaimsExtensions.cs | 2 +- src/Api/SecretsManager/Controllers/ProjectsController.cs | 2 +- src/Api/SecretsManager/Controllers/SecretsController.cs | 2 +- src/Api/SecretsManager/Controllers/SecretsTrashController.cs | 2 +- src/Api/Startup.cs | 3 ++- src/Api/Utilities/ServiceCollectionExtensions.cs | 2 +- src/Core/AdminConsole/Models/Data/Permissions.cs | 2 +- src/Core/{ => Auth}/Identity/Claims.cs | 2 +- .../Identity/CustomIdentityServiceCollectionExtensions.cs | 0 src/Core/{ => Auth}/Identity/IdentityClientType.cs | 2 +- src/Core/{ => Auth}/IdentityServer/ApiScopes.cs | 2 +- .../ConfigureOpenIdConnectDistributedOptions.cs | 2 +- .../IdentityServer/DistributedCacheCookieManager.cs | 2 +- .../IdentityServer/DistributedCacheTicketDataFormatter.cs | 2 +- .../{ => Auth}/IdentityServer/DistributedCacheTicketStore.cs | 2 +- .../SendAccess/SendAccessClaimsPrincipalExtensions.cs | 2 +- src/Core/Context/CurrentContext.cs | 2 +- src/Core/Context/ICurrentContext.cs | 2 +- src/Core/Enums/AccessClientType.cs | 2 +- .../SelfHosted/SelfHostedSyncSponsorshipsCommand.cs | 2 +- src/Core/Platform/Push/Engines/RelayPushEngine.cs | 4 ++-- .../Platform/PushRegistration/RelayPushRegistrationService.cs | 4 ++-- .../Commands/Projects/Interfaces/ICreateProjectCommand.cs | 2 +- .../Services/Implementations/LaunchDarklyFeatureService.cs | 2 +- src/Core/Utilities/CoreHelpers.cs | 2 +- src/Events/Startup.cs | 2 +- src/Identity/IdentityServer/ApiResources.cs | 4 ++-- .../ClientProviders/InstallationClientProvider.cs | 2 +- .../IdentityServer/ClientProviders/InternalClientProvider.cs | 2 +- .../ClientProviders/OrganizationClientProvider.cs | 4 ++-- .../ClientProviders/SecretsManagerApiKeyProvider.cs | 2 +- .../IdentityServer/ClientProviders/UserClientProvider.cs | 2 +- src/Identity/IdentityServer/ProfileService.cs | 2 +- .../IdentityServer/RequestValidators/BaseRequestValidator.cs | 2 +- .../RequestValidators/CustomTokenRequestValidator.cs | 2 +- .../RequestValidators/SendAccess/SendAccessGrantValidator.cs | 2 +- .../SendAccess/SendEmailOtpRequestValidator.cs | 2 +- .../SendAccess/SendPasswordRequestValidator.cs | 2 +- .../IdentityServer/StaticClients/SendClientBuilder.cs | 4 ++-- src/Identity/Utilities/ServiceCollectionExtensions.cs | 4 ++-- src/Notifications/Startup.cs | 2 +- src/SharedWeb/Utilities/ServiceCollectionExtensions.cs | 2 -- .../SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs | 2 +- .../SendAccessGrantValidatorIntegrationTests.cs | 2 +- .../SendEmailOtpReqestValidatorIntegrationTests.cs | 2 +- .../SendPasswordRequestValidatorIntegrationTests.cs | 4 ++-- .../ClientProviders/InstallationClientProviderTests.cs | 2 +- .../ClientProviders/InternalClientProviderTests.cs | 2 +- .../SendAccess/SendAccessGrantValidatorTests.cs | 4 ++-- .../SendAccess/SendEmailOtpRequestValidatorTests.cs | 4 ++-- .../SendAccess/SendPasswordRequestValidatorTests.cs | 4 ++-- .../IdentityServer/SendPasswordRequestValidatorTests.cs | 4 ++-- 54 files changed, 65 insertions(+), 65 deletions(-) rename src/Core/{ => Auth}/Identity/Claims.cs (98%) rename src/Core/{ => Auth}/Identity/CustomIdentityServiceCollectionExtensions.cs (100%) rename src/Core/{ => Auth}/Identity/IdentityClientType.cs (75%) rename src/Core/{ => Auth}/IdentityServer/ApiScopes.cs (96%) rename src/Core/{ => Auth}/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs (97%) rename src/Core/{ => Auth}/IdentityServer/DistributedCacheCookieManager.cs (98%) rename src/Core/{ => Auth}/IdentityServer/DistributedCacheTicketDataFormatter.cs (98%) rename src/Core/{ => Auth}/IdentityServer/DistributedCacheTicketStore.cs (97%) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs index 1a5fe07c21..9f37c35f78 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs @@ -1,9 +1,9 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index 546bbfb7c9..db574e71c5 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography.X509Certificates; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Settings; @@ -416,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider SPOptions = spOptions, SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme, - CookieManager = new IdentityServer.DistributedCacheCookieManager(), + CookieManager = new DistributedCacheCookieManager(), }; options.IdentityProviders.Add(idp); diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs index e21d153bab..a3af3669ac 100644 --- a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs +++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs @@ -1,9 +1,9 @@ #nullable enable using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Authorization; diff --git a/src/Api/SecretsManager/Controllers/ProjectsController.cs b/src/Api/SecretsManager/Controllers/ProjectsController.cs index 11b840accf..5dce032ece 100644 --- a/src/Api/SecretsManager/Controllers/ProjectsController.cs +++ b/src/Api/SecretsManager/Controllers/ProjectsController.cs @@ -4,10 +4,10 @@ using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index e32d5cd581..e263b9747d 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -4,10 +4,10 @@ using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs index 275e76cc99..d791fa2341 100644 --- a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs @@ -1,8 +1,8 @@ using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.Commands.Trash.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 3a08c4fe8a..2d306c4435 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -13,7 +13,6 @@ using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; -using Bit.Core.IdentityServer; using Bit.SharedWeb.Health; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; @@ -33,6 +32,8 @@ using Bit.Core.Tools.ImportFeatures; using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; +using Bit.Core.Auth.IdentityServer; + #if !OSS using Bit.Commercial.Core.SecretsManager; diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 0d8c3dec38..b956fc73bb 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/Models/Data/Permissions.cs b/src/Core/AdminConsole/Models/Data/Permissions.cs index def468f18d..75bf2db8c9 100644 --- a/src/Core/AdminConsole/Models/Data/Permissions.cs +++ b/src/Core/AdminConsole/Models/Data/Permissions.cs @@ -1,5 +1,5 @@ using System.Text.Json.Serialization; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Models.Data; diff --git a/src/Core/Identity/Claims.cs b/src/Core/Auth/Identity/Claims.cs similarity index 98% rename from src/Core/Identity/Claims.cs rename to src/Core/Auth/Identity/Claims.cs index 39a036f3f9..ac78e987ae 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Auth/Identity/Claims.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Identity; +namespace Bit.Core.Auth.Identity; public static class Claims { diff --git a/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs b/src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs similarity index 100% rename from src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs rename to src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs diff --git a/src/Core/Identity/IdentityClientType.cs b/src/Core/Auth/Identity/IdentityClientType.cs similarity index 75% rename from src/Core/Identity/IdentityClientType.cs rename to src/Core/Auth/Identity/IdentityClientType.cs index 9c43007f25..113877135d 100644 --- a/src/Core/Identity/IdentityClientType.cs +++ b/src/Core/Auth/Identity/IdentityClientType.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Identity; +namespace Bit.Core.Auth.Identity; public enum IdentityClientType : byte { diff --git a/src/Core/IdentityServer/ApiScopes.cs b/src/Core/Auth/IdentityServer/ApiScopes.cs similarity index 96% rename from src/Core/IdentityServer/ApiScopes.cs rename to src/Core/Auth/IdentityServer/ApiScopes.cs index 77ccb5a58a..8836a168b6 100644 --- a/src/Core/IdentityServer/ApiScopes.cs +++ b/src/Core/Auth/IdentityServer/ApiScopes.cs @@ -1,6 +1,6 @@ using Duende.IdentityServer.Models; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public static class ApiScopes { diff --git a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs b/src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs similarity index 97% rename from src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs rename to src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs index 381f81dea5..5319539050 100644 --- a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs +++ b/src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class ConfigureOpenIdConnectDistributedOptions : IPostConfigureOptions { diff --git a/src/Core/IdentityServer/DistributedCacheCookieManager.cs b/src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs similarity index 98% rename from src/Core/IdentityServer/DistributedCacheCookieManager.cs rename to src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs index a01ff63d8f..138aeaf7e8 100644 --- a/src/Core/IdentityServer/DistributedCacheCookieManager.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheCookieManager : ICookieManager { diff --git a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs b/src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs similarity index 98% rename from src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs rename to src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs index ad3fdee6f0..565d02a838 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheTicketDataFormatter : ISecureDataFormat { diff --git a/src/Core/IdentityServer/DistributedCacheTicketStore.cs b/src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs similarity index 97% rename from src/Core/IdentityServer/DistributedCacheTicketStore.cs rename to src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs index ddf66f04ec..675b0cd7a5 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketStore.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Caching.Distributed; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheTicketStore : ITicketStore { diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs index 7ae7355ba4..f944de381e 100644 --- a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs +++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Auth.UserFeatures.SendAccess; diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 85c8a81523..e824a30a0e 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -6,10 +6,10 @@ using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index 42843ce6d7..417e220ba2 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -3,9 +3,9 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; diff --git a/src/Core/Enums/AccessClientType.cs b/src/Core/Enums/AccessClientType.cs index fb757c6dd6..c7336ee40d 100644 --- a/src/Core/Enums/AccessClientType.cs +++ b/src/Core/Enums/AccessClientType.cs @@ -1,4 +1,4 @@ -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Enums; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs index 76e7b6bb2a..9a995a9cf0 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs @@ -1,9 +1,9 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.IdentityServer; using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.IdentityServer; using Bit.Core.Models.Api.Request.OrganizationSponsorships; using Bit.Core.Models.Api.Response.OrganizationSponsorships; using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; diff --git a/src/Core/Platform/Push/Engines/RelayPushEngine.cs b/src/Core/Platform/Push/Engines/RelayPushEngine.cs index 66b0229315..cff077c850 100644 --- a/src/Core/Platform/Push/Engines/RelayPushEngine.cs +++ b/src/Core/Platform/Push/Engines/RelayPushEngine.cs @@ -1,6 +1,6 @@ -using Bit.Core.Context; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Repositories; diff --git a/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs index 96a259ecf8..0925e92f64 100644 --- a/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Platform.Push; using Bit.Core.Services; diff --git a/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs b/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs index db377e220e..a1793cc73a 100644 --- a/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs +++ b/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Commands.Projects.Interfaces; diff --git a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs index 1fb2348c5a..f118146cb1 100644 --- a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs +++ b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs @@ -1,8 +1,8 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.Identity; using Bit.Core.Context; -using Bit.Core.Identity; using Bit.Core.Settings; using Bit.Core.Utilities; using LaunchDarkly.Logging; diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 64a038be07..813eb6d1aa 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -16,10 +16,10 @@ using Azure.Storage.Queues.Models; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Enums; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.Identity; using Bit.Core.Settings; using Duende.IdentityModel; using Microsoft.AspNetCore.DataProtection; diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index b498bce229..fdeaad04b2 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,6 +1,6 @@ using System.Globalization; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index 61f3dd10ba..d225a7ea33 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -1,5 +1,5 @@ -using Bit.Core.Identity; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs index cfa0dee0e6..566b0395b8 100644 --- a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Platform.Installations; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs index 3cab275a8f..70c1e2e06a 100644 --- a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs @@ -1,7 +1,7 @@ #nullable enable using System.Diagnostics; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs index 2bcae37ee2..86a1272496 100644 --- a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs @@ -1,9 +1,9 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.Repositories; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs index 11022a40e5..628163ae74 100644 --- a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs index 29d036b893..2d380acdf6 100644 --- a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs @@ -3,9 +3,9 @@ using System.Collections.ObjectModel; using System.Security.Claims; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Utilities; using Duende.IdentityModel; diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs index 74173a7e9d..9ea8fcf471 100644 --- a/src/Identity/IdentityServer/ProfileService.cs +++ b/src/Identity/IdentityServer/ProfileService.cs @@ -1,9 +1,9 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 5a8cb8645e..e57ed1c85f 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -8,12 +8,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Api; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index c7bf1a77db..1495973b80 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -3,11 +3,11 @@ using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.IdentityServer; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 5fe0b7b724..2ecc5a9704 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using Bit.Core; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index e26556eb80..ca48c4fbec 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,6 +1,6 @@ using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Identity; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Identity.IdentityServer.Enums; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs index 4eade01a49..a514e3bc8b 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; using Bit.Identity.IdentityServer.Enums; diff --git a/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs index 7197d435ed..6424316505 100644 --- a/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs +++ b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Identity.IdentityServer.Enums; using Duende.IdentityServer.Models; diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 95c067d884..9d062e5c06 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -1,5 +1,5 @@ -using Bit.Core.Auth.Repositories; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Repositories; using Bit.Core.Settings; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index c939d0d2fd..eb3c3f8682 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -1,5 +1,5 @@ using System.Globalization; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 0dd5431dd7..4f0d0d4397 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -30,8 +30,6 @@ using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; using Bit.Core.OrganizationFeatures; diff --git a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs index bf5322d916..ac625dad9e 100644 --- a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs +++ b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs @@ -1,6 +1,6 @@ using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures.SendAccess; -using Bit.Core.Identity; using Xunit; namespace Bit.Core.Test.Auth.UserFeatures.SendAccess; diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs index 3b0cf2c282..ca6417d49c 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs @@ -1,6 +1,6 @@ using Bit.Core; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs index 9d9bc03ef5..9a097cc061 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -1,6 +1,6 @@ using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs index 232adb6884..856ffe1f6e 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Sends; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; diff --git a/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs index b53e6ea15f..f9949c0c3a 100644 --- a/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs +++ b/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Platform.Installations; using Bit.Identity.IdentityServer.ClientProviders; using Duende.IdentityModel; diff --git a/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs index 4e5e659218..dda48f2af3 100644 --- a/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs +++ b/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Bit.Identity.IdentityServer.ClientProviders; using Duende.IdentityModel; diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index e651709c47..017ad70354 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; using Bit.Core; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 2fd21fd4cf..70a1585d8b 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs index e2b8b49830..e77626d37b 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs index ccee33d8c7..2ad1039a98 100644 --- a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; From e456b4ce219dd4ee166e98c415027ee4a445b537 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 4 Sep 2025 12:23:14 -0400 Subject: [PATCH 33/85] add feature flag (#6284) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 058f4eac69..57798204ea 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -128,6 +128,7 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; + public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; From 8b30c33eaebf244661fb876006d63093bbdae2e1 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 4 Sep 2025 12:54:24 -0500 Subject: [PATCH 34/85] PM-25413 no badRequest result because of error from Onyx (#6285) --- .../Controllers/FreshdeskController.cs | 15 ++++-- .../Controllers/FreshdeskControllerTests.cs | 51 +++++++++++++++++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 3f26e28786..a854d2d49f 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -152,6 +152,12 @@ public class FreshdeskController : Controller return new BadRequestResult(); } + // if there is no description, then we don't send anything to onyx + if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) + { + return Ok(); + } + // create the onyx `answer-with-citation` request var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId); var onyxRequest = new HttpRequestMessage(HttpMethod.Post, @@ -164,9 +170,12 @@ public class FreshdeskController : Controller // the CallOnyxApi will return a null if we have an error response if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg)) { - return BadRequest( - string.Format("Failed to get a valid response from Onyx API. Response: {0}", - JsonSerializer.Serialize(onyxJsonResponse ?? new OnyxAnswerWithCitationResponseModel()))); + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequestModel), + JsonSerializer.Serialize(onyxJsonResponse)); + + return Ok(); // return ok so we don't retry } // add the answer as a note to the ticket diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs index f0a34ff232..8fd0769a02 100644 --- a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs +++ b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ReceivedExtensions; @@ -126,7 +127,7 @@ public class FreshdeskControllerTests [Theory] [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_invalid_onyx_response_results_in_BadRequest( + public async Task PostWebhookOnyxAi_invalid_onyx_response_results_is_logged( string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, SutProvider sutProvider) { @@ -150,8 +151,18 @@ public class FreshdeskControllerTests var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); - var result = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); + + var _logger = sutProvider.GetDependency>(); + + // workaround because _logger.Received(1).LogWarning(...) does not work + _logger.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Log" && c.GetArguments()[1].ToString().Contains("Error getting answer from Onyx AI")); + + // sent call to Onyx API - but we got an error response + _ = mockOnyxHttpMessageHandler.Received(1).Send(Arg.Any(), Arg.Any()); + // did not call freshdesk to add a note since onyx failed + _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Theory] @@ -174,10 +185,9 @@ public class FreshdeskControllerTests .Returns(mockFreshdeskAddNoteResponse); var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); - // mocking Onyx api response given a ticket description var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); - onyxResponse.ErrorMsg = string.Empty; + onyxResponse.ErrorMsg = "string.Empty"; var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(onyxResponse)) @@ -195,6 +205,37 @@ public class FreshdeskControllerTests Assert.Equal(StatusCodes.Status200OK, result.StatusCode); } + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhookOnyxAi_ticket_description_is_empty_return_success( + string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, + SutProvider sutProvider) + { + var billingSettings = sutProvider.GetDependency>().Value; + billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); + billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); + + model.TicketDescriptionText = " "; // empty description + + // mocking freshdesk api add note request (POST) + var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); + var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); + + // mocking Onyx api response given a ticket description + var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); + var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); + sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var result = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, result.StatusCode); + _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); + _ = mockOnyxHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + public class MockHttpMessageHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) From 1b0be3e87f22644ee67dcfe9b0e199e264045738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:22:50 +0100 Subject: [PATCH 35/85] [PM-22839] Add SSO configuration fields to organization user details for hiding device approvals page (#6245) * Add SsoEnabled field to OrganizationUserOrganizationDetailsView - Updated OrganizationUserOrganizationDetailsViewQuery to include SsoEnabled property. - Modified SQL view to select SsoEnabled from SsoConfig. - Created migration script to alter the view and refresh dependent views. * Enhance OrganizationUserRepositoryTests to include SSO configuration - Added ISsoConfigRepository dependency to GetManyDetailsByUserAsync test. - Created SsoConfigurationData instance and integrated SSO configuration checks in assertions. - Updated tests to validate SSO-related properties in the response model. * Add SSO properties to ProfileOrganizationResponseModel and OrganizationUserOrganizationDetails - Introduced SsoEnabled and SsoMemberDecryptionType fields in ProfileOrganizationResponseModel. - Added SsoEnabled property to OrganizationUserOrganizationDetails for enhanced SSO configuration support. --- .../ProfileOrganizationResponseModel.cs | 4 + .../OrganizationUserOrganizationDetails.cs | 1 + ...izationUserOrganizationDetailsViewQuery.cs | 1 + ...rganizationUserOrganizationDetailsView.sql | 1 + .../OrganizationUserRepositoryTests.cs | 21 ++++- ...8-25_00_OrgUserOrgDetailsAddSsoEnabled.sql | 86 +++++++++++++++++++ 6 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 util/Migrator/DbScripts/2025-08-25_00_OrgUserOrgDetailsAddSsoEnabled.sql diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index e421c3247e..fd2bfe06dc 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -78,12 +78,14 @@ public class ProfileOrganizationResponseModel : ResponseModel UseRiskInsights = organization.UseRiskInsights; UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; + SsoEnabled = organization.SsoEnabled ?? false; if (organization.SsoConfig != null) { var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig); KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl); KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; + SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType; } } @@ -160,4 +162,6 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool IsAdminInitiated { get; set; } + public bool SsoEnabled { get; set; } + public MemberDecryptionType? SsoMemberDecryptionType { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index bad06ccf64..b7e573c4e6 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -49,6 +49,7 @@ public class OrganizationUserOrganizationDetails public string ProviderName { get; set; } public ProviderType? ProviderType { get; set; } public string FamilySponsorshipFriendlyName { get; set; } + public bool? SsoEnabled { get; set; } public string SsoConfig { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 71bf113416..26d3a128fc 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -56,6 +56,7 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery Date: Fri, 5 Sep 2025 12:01:14 +0100 Subject: [PATCH 36/85] [PM-21752] Add granular events for collection management settings (#6269) * Add new event types for collection management settings in EventType enum * Refactor collection management settings update process in OrganizationsController and IOrganizationService. Introduced UpdateCollectionManagementSettingsAsync method to streamline updates and logging for collection management settings. * Add unit tests for collection management settings updates in OrganizationsController and OrganizationService. Implemented tests to verify the successful update of collection management settings and the logging of specific events when settings are changed. Added error handling for cases where the organization is not found. * Refactor collection management settings handling in OrganizationsController and IOrganizationService. Updated the UpdateCollectionManagementSettingsAsync method to accept a single settings object, simplifying the parameter list and improving code readability. Introduced a new OrganizationCollectionManagementSettings model to encapsulate collection management settings. Adjusted related tests to reflect these changes. * Add Obsolete attribute to Organization_CollectionManagement_Updated event in EventType enum --- .../Controllers/OrganizationsController.cs | 8 +- ...nCollectionManagementUpdateRequestModel.cs | 16 ++- src/Core/AdminConsole/Enums/EventType.cs | 11 +- ...rganizationCollectionManagementSettings.cs | 9 ++ .../Services/IOrganizationService.cs | 4 +- .../Implementations/OrganizationService.cs | 74 +++++++++++- .../OrganizationsControllerTests.cs | 39 +++++++ .../Services/OrganizationServiceTests.cs | 107 +++++++++++++++++- 8 files changed, 242 insertions(+), 26 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 8b1a6243c3..17e6a60cd9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -554,18 +554,12 @@ public class OrganizationsController : Controller [HttpPut("{id}/collection-management")] public async Task PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) { - var organization = await _organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - if (!await _currentContext.OrganizationOwner(id)) { throw new NotFoundException(); } - await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); + var organization = await _organizationService.UpdateCollectionManagementSettingsAsync(id, model.ToSettings()); var plan = await _pricingClient.GetPlan(organization.PlanType); return new OrganizationResponseModel(organization, plan); } diff --git a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs index 829840c896..93866161c0 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Services; +using Bit.Core.AdminConsole.Models.Business; namespace Bit.Api.Models.Request.Organizations; @@ -10,12 +9,11 @@ public class OrganizationCollectionManagementUpdateRequestModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, IFeatureService featureService) + public OrganizationCollectionManagementSettings ToSettings() => new() { - existingOrganization.LimitCollectionCreation = LimitCollectionCreation; - existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion; - existingOrganization.LimitItemDeletion = LimitItemDeletion; - existingOrganization.AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems; - return existingOrganization; - } + LimitCollectionCreation = LimitCollectionCreation, + LimitCollectionDeletion = LimitCollectionDeletion, + LimitItemDeletion = LimitItemDeletion, + AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems + }; } diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 32ea4a64e9..81501fd6ec 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -70,7 +70,16 @@ public enum EventType : int Organization_EnabledKeyConnector = 1606, Organization_DisabledKeyConnector = 1607, Organization_SponsorshipsSynced = 1608, - Organization_CollectionManagement_Updated = 1609, + [Obsolete("Use other specific Organization_CollectionManagement events instead")] + Organization_CollectionManagement_Updated = 1609, // TODO: Will be removed in PM-25315 + Organization_CollectionManagement_LimitCollectionCreationEnabled = 1610, + Organization_CollectionManagement_LimitCollectionCreationDisabled = 1611, + Organization_CollectionManagement_LimitCollectionDeletionEnabled = 1612, + Organization_CollectionManagement_LimitCollectionDeletionDisabled = 1613, + Organization_CollectionManagement_LimitItemDeletionEnabled = 1614, + Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, + Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, + Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, Policy_Updated = 1700, diff --git a/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs b/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs new file mode 100644 index 0000000000..aff2244598 --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.AdminConsole.Models.Business; + +public record OrganizationCollectionManagementSettings +{ + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + public bool LimitItemDeletion { get; set; } + public bool AllowAdminAccessToAllCollectionItems { get; set; } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 8c47ae049c..94df74afdf 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -2,6 +2,7 @@ #nullable disable using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -19,7 +20,8 @@ public interface IOrganizationService Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); - Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated); + Task UpdateAsync(Organization organization, bool updateBilling = false); + Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f418737508..57eb4f51de 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -378,8 +379,7 @@ public class OrganizationService : IOrganizationService } } - public async Task UpdateAsync(Organization organization, bool updateBilling = false, - EventType eventType = EventType.Organization_Updated) + public async Task UpdateAsync(Organization organization, bool updateBilling = false) { if (organization.Id == default(Guid)) { @@ -395,7 +395,7 @@ public class OrganizationService : IOrganizationService } } - await ReplaceAndUpdateCacheAsync(organization, eventType); + await ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { @@ -420,11 +420,35 @@ public class OrganizationService : IOrganizationService }, }); } + } - if (eventType == EventType.Organization_CollectionManagement_Updated) + public async Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings) + { + var existingOrganization = await _organizationRepository.GetByIdAsync(organizationId); + if (existingOrganization == null) { - await _pushNotificationService.PushSyncOrganizationCollectionManagementSettingsAsync(organization); + throw new NotFoundException(); } + + // Create logging actions based on what will change + var loggingActions = CreateCollectionManagementLoggingActions(existingOrganization, settings); + + existingOrganization.LimitCollectionCreation = settings.LimitCollectionCreation; + existingOrganization.LimitCollectionDeletion = settings.LimitCollectionDeletion; + existingOrganization.LimitItemDeletion = settings.LimitItemDeletion; + existingOrganization.AllowAdminAccessToAllCollectionItems = settings.AllowAdminAccessToAllCollectionItems; + existingOrganization.RevisionDate = DateTime.UtcNow; + + await ReplaceAndUpdateCacheAsync(existingOrganization); + + if (loggingActions.Any()) + { + await Task.WhenAll(loggingActions.Select(action => action())); + } + + await _pushNotificationService.PushSyncOrganizationCollectionManagementSettingsAsync(existingOrganization); + + return existingOrganization; } public async Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type) @@ -1214,4 +1238,44 @@ public class OrganizationService : IOrganizationService return status; } + + private List> CreateCollectionManagementLoggingActions( + Organization existingOrganization, OrganizationCollectionManagementSettings settings) + { + var loggingActions = new List>(); + + if (existingOrganization.LimitCollectionCreation != settings.LimitCollectionCreation) + { + var eventType = settings.LimitCollectionCreation + ? EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled + : EventType.Organization_CollectionManagement_LimitCollectionCreationDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.LimitCollectionDeletion != settings.LimitCollectionDeletion) + { + var eventType = settings.LimitCollectionDeletion + ? EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled + : EventType.Organization_CollectionManagement_LimitCollectionDeletionDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.LimitItemDeletion != settings.LimitItemDeletion) + { + var eventType = settings.LimitItemDeletion + ? EventType.Organization_CollectionManagement_LimitItemDeletionEnabled + : EventType.Organization_CollectionManagement_LimitItemDeletionDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.AllowAdminAccessToAllCollectionItems != settings.AllowAdminAccessToAllCollectionItems) + { + var eventType = settings.AllowAdminAccessToAllCollectionItems + ? EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled + : EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + return loggingActions; + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 3484c9a995..00fd3c3b4e 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -2,10 +2,12 @@ using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Models.Request.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -29,6 +31,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; using Xunit; @@ -293,4 +296,40 @@ public class OrganizationsControllerTests : IDisposable Assert.True(result.ResetPasswordEnabled); } + + [Theory, AutoData] + public async Task PutCollectionManagement_ValidRequest_Success( + Organization organization, + OrganizationCollectionManagementUpdateRequestModel model) + { + // Arrange + _currentContext.OrganizationOwner(organization.Id).Returns(true); + + var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + _pricingClient.GetPlan(Arg.Any()).Returns(plan); + + _organizationService + .UpdateCollectionManagementSettingsAsync( + organization.Id, + Arg.Is(s => + s.LimitCollectionCreation == model.LimitCollectionCreation && + s.LimitCollectionDeletion == model.LimitCollectionDeletion && + s.LimitItemDeletion == model.LimitItemDeletion && + s.AllowAdminAccessToAllCollectionItems == model.AllowAdminAccessToAllCollectionItems)) + .Returns(organization); + + // Act + await _sut.PutCollectionManagement(organization.Id, model); + + // Assert + await _organizationService + .Received(1) + .UpdateCollectionManagementSettingsAsync( + organization.Id, + Arg.Is(s => + s.LimitCollectionCreation == model.LimitCollectionCreation && + s.LimitCollectionDeletion == model.LimitCollectionDeletion && + s.LimitItemDeletion == model.LimitItemDeletion && + s.AllowAdminAccessToAllCollectionItems == model.AllowAdminAccessToAllCollectionItems)); + } } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index e3f26a898d..33f2e78799 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; @@ -27,7 +28,6 @@ using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Fakes; using NSubstitute; using NSubstitute.ExceptionExtensions; -using NSubstitute.ReceivedExtensions; using NSubstitute.ReturnsExtensions; using Stripe; using Xunit; @@ -42,8 +42,6 @@ public class OrganizationServiceTests { private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); - - [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] @@ -1229,6 +1227,109 @@ public class OrganizationServiceTests .GetByIdentifierAsync(Arg.Is(id => id == organization.Identifier)); } + [Theory] + [BitAutoData(false, true, false, true)] + [BitAutoData(true, false, true, false)] + public async Task UpdateCollectionManagementSettingsAsync_WhenSettingsChanged_LogsSpecificEvents( + bool newLimitCollectionCreation, + bool newLimitCollectionDeletion, + bool newLimitItemDeletion, + bool newAllowAdminAccessToAllCollectionItems, + Organization existingOrganization, SutProvider sutProvider) + { + // Arrange + existingOrganization.LimitCollectionCreation = false; + existingOrganization.LimitCollectionDeletion = false; + existingOrganization.LimitItemDeletion = false; + existingOrganization.AllowAdminAccessToAllCollectionItems = false; + + sutProvider.GetDependency() + .GetByIdAsync(existingOrganization.Id) + .Returns(existingOrganization); + + var settings = new OrganizationCollectionManagementSettings + { + LimitCollectionCreation = newLimitCollectionCreation, + LimitCollectionDeletion = newLimitCollectionDeletion, + LimitItemDeletion = newLimitItemDeletion, + AllowAdminAccessToAllCollectionItems = newAllowAdminAccessToAllCollectionItems + }; + + // Act + await sutProvider.Sut.UpdateCollectionManagementSettingsAsync(existingOrganization.Id, settings); + + // Assert + var eventService = sutProvider.GetDependency(); + if (newLimitCollectionCreation) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled)); + } + + if (newLimitCollectionDeletion) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled)); + } + + if (newLimitItemDeletion) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitItemDeletionEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitItemDeletionEnabled)); + } + + if (newAllowAdminAccessToAllCollectionItems) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateCollectionManagementSettingsAsync_WhenOrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, OrganizationCollectionManagementSettings settings, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns((Organization)null); + + // Act/Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateCollectionManagementSettingsAsync(organizationId, settings)); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organizationId); + } + // Must set real guids in order for dictionary of guids to not throw aggregate exceptions private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository) { From 6d4129c6b7db3c672207d2ba1304297865cfe712 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:36:01 -0400 Subject: [PATCH 37/85] [PM-20595] Add Policy for Send access (#6282) * feat: add policy to API startup and Policies class to hold the static strings * test: add snapshot testing for constants to help with rust mappings * doc: add docs for send access --- src/Api/Startup.cs | 7 ++ src/Core/Auth/Identity/Policies.cs | 10 +++ .../SendAccess/SendAccessConstants.cs | 4 +- .../SendAccess/SendAccessGrantValidator.cs | 6 +- .../RequestValidators/SendAccess/readme.md | 66 +++++++++++++++++ .../SendAccess/SendConstantsSnapshotTests.cs | 73 +++++++++++++++++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/Core/Auth/Identity/Policies.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2d306c4435..1d5a1609f4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -33,6 +33,7 @@ using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; #if !OSS @@ -145,6 +146,12 @@ public class Startup (c.Value.Contains(ApiScopes.Api) || c.Value.Contains(ApiScopes.ApiSecrets)) )); }); + config.AddPolicy(Policies.Send, configurePolicy: policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiSendAccess); + policy.RequireClaim(Claims.SendAccessClaims.SendId); + }); }); services.AddScoped(); diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs new file mode 100644 index 0000000000..78d86d06a4 --- /dev/null +++ b/src/Core/Auth/Identity/Policies.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Auth.Identity; + +public static class Policies +{ + /// + /// Policy for managing access to the Send feature. + /// + public const string Send = "Send"; // [Authorize(Policy = Policies.Send)] + // TODO: migrate other existing policies to use this class +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index fae7ba4215..17ec387411 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -5,6 +5,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; /// /// String constants for the Send Access user feature +/// Most of these need to be synced with the `bitwarden-auth` crate in the SDK. +/// There is snapshot testing to help ensure this. /// public static class SendAccessConstants { @@ -41,7 +43,7 @@ public static class SendAccessConstants /// /// The sendId is missing from the request. /// - public const string MissingSendId = "send_id_required"; + public const string SendIdRequired = "send_id_required"; /// /// The sendId is invalid, does not match a known send. /// diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 2ecc5a9704..d9ae946d16 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -23,7 +23,7 @@ public class SendAccessGrantValidator( private static readonly Dictionary _sendGrantValidatorErrorDescriptions = new() { - { SendAccessConstants.GrantValidatorResults.MissingSendId, $"{SendAccessConstants.TokenRequest.SendId} is required." }, + { SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, { SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } }; @@ -90,7 +90,7 @@ public class SendAccessGrantValidator( // if the sendId is null then the request is the wrong shape and the request is invalid if (sendId == null) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.MissingSendId); + return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired); } // the send_id is not null so the request is the correct shape, so we will attempt to parse it try @@ -125,7 +125,7 @@ public class SendAccessGrantValidator( return error switch { // Request is the wrong shape - SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult( + SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md new file mode 100644 index 0000000000..afab13a156 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md @@ -0,0 +1,66 @@ +Send Access Request Validation +=== + +This feature supports the ability of Tools to require specific claims for access to sends. + +In order to access Send data a user must meet the requirements laid out in these request validators. + +# ***Important: String Constants*** + +The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. + +There is snapshot testing that will fail if the strings change to help detect unintended changes to the string constants. + +# Custom Claims + +Send access tokens contain custom claims specific to the Send the Send grant type. + +1. `send_id` - is always included in the issued access token. This is the `GUID` of the request Send. +1. `send_email` - only set when the Send requires `EmailOtp` authentication type. +1. `type` - this will always be `Send` + +# Authentication methods + +## `NeverAuthenticate` + +For a Send to be in this state two things can be true: +1. The Send has been modified and no longer allows access. +2. The Send does not exist. + +## `NotAuthenticated` + +In this scenario the Send is not protected by any added authentication or authorization and the access token is issued to the requesting user. + +## `ResourcePassword` + +In this scenario the Send is password protected and a user must supply the correct password hash to be issued an access token. + +## `EmailOtp` + +In this scenario the Send is only accessible to owners of specific email addresses. The user must submit a correct email. Once the email has been entered then ownership of the email must be established via OTP. The Otp is sent to the aforementioned email and must be supplied, along with the email, to be issued an access token. + +# Send Access Request Validation + +## Required Parameters + +### All Requests +- `send_id` - Base64 URL-encoded GUID of the send being accessed + +### Password Protected Sends +- `password_hash_b64` - client hashed Base64-encoded password. + +### Email OTP Protected Sends +- `email` - Email address associated with the send +- `otp` - One-time password (optional - if missing, OTP is generated and sent) + +## Error Responses + +All errors include a custom response field: +```json +{ + "error": "invalid_request|invalid_grant", + "error_description": "Human readable description", + "send_access_error_type": "specific_error_code" +} +``` + diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs new file mode 100644 index 0000000000..95a0a6675b --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -0,0 +1,73 @@ +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +/// +/// Snapshot tests to ensure the string constants in do not change unintentionally. +/// If you change any of these values, please ensure you understand the impact and update the SDK accordingly. +/// If you intentionally change any of these values, please update the tests to reflect the new expected values. +/// +public class SendConstantsSnapshotTests +{ + [Fact] + public void SendAccessError_Constant_HasCorrectValue() + { + // Assert + Assert.Equal("send_access_error_type", SendAccessConstants.SendAccessError); + } + + [Fact] + public void TokenRequest_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("send_id", SendAccessConstants.TokenRequest.SendId); + Assert.Equal("password_hash_b64", SendAccessConstants.TokenRequest.ClientB64HashedPassword); + Assert.Equal("email", SendAccessConstants.TokenRequest.Email); + Assert.Equal("otp", SendAccessConstants.TokenRequest.Otp); + } + + [Fact] + public void GrantValidatorResults_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("valid_send_guid", SendAccessConstants.GrantValidatorResults.ValidSendGuid); + Assert.Equal("send_id_required", SendAccessConstants.GrantValidatorResults.SendIdRequired); + Assert.Equal("send_id_invalid", SendAccessConstants.GrantValidatorResults.InvalidSendId); + } + + [Fact] + public void PasswordValidatorResults_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("password_hash_b64_invalid", SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch); + Assert.Equal("password_hash_b64_required", SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired); + } + + [Fact] + public void EmailOtpValidatorResults_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("email_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); + Assert.Equal("email_required", SendAccessConstants.EmailOtpValidatorResults.EmailRequired); + Assert.Equal("email_and_otp_required_otp_sent", SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); + Assert.Equal("otp_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); + Assert.Equal("otp_generation_failed", SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); + } + + [Fact] + public void OtpToken_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("send_access", SendAccessConstants.OtpToken.TokenProviderName); + Assert.Equal("email_otp", SendAccessConstants.OtpToken.Purpose); + Assert.Equal("{0}_{1}", SendAccessConstants.OtpToken.TokenUniqueIdentifier); + } + + [Fact] + public void OtpEmail_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("Your Bitwarden Send verification code is {0}", SendAccessConstants.OtpEmail.Subject); + } +} From 87bc9299e6fb4c95e420d74f2af9eb4e46400ac0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 5 Sep 2025 11:15:01 -0400 Subject: [PATCH 38/85] [PM-23309] Admin Console Credit is not Displaying Decimals (#6280) * fix: update calculation to be decimal * fix: update record type property to decimal * tests: add tests to service and update test names --- .../Models/Responses/PaymentMethodResponse.cs | 2 +- src/Core/Billing/Models/PaymentMethod.cs | 2 +- .../Implementations/SubscriberService.cs | 2 +- .../Services/SubscriberServiceTests.cs | 225 ++++++++++++++---- 4 files changed, 183 insertions(+), 48 deletions(-) diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs index fd248a0a00..a54ac0a876 100644 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs @@ -4,7 +4,7 @@ using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; public record PaymentMethodResponse( - long AccountCredit, + decimal AccountCredit, PaymentSource PaymentSource, string SubscriptionStatus, TaxInformation TaxInformation) diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index 14ee79b714..10eab97a8f 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -6,7 +6,7 @@ using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Models; public record PaymentMethod( - long AccountCredit, + decimal AccountCredit, PaymentSource PaymentSource, string SubscriptionStatus, TaxInformation TaxInformation) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 84d41f829c..378e84f15a 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -345,7 +345,7 @@ public class SubscriberService( return PaymentMethod.Empty; } - var accountCredit = customer.Balance * -1 / 100; + var accountCredit = customer.Balance * -1 / 100M; var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer); diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 0df8d1bfcc..600f9d9be2 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -329,13 +329,165 @@ public class SubscriberServiceTests #endregion #region GetPaymentMethod + [Theory, BitAutoData] public async Task GetPaymentMethod_NullSubscriber_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentSource(null)); [Theory, BitAutoData] - public async Task GetPaymentMethod_Braintree_NoDefaultPaymentMethod_ReturnsNull( + public async Task GetPaymentMethod_WithNegativeStripeAccountBalance_ReturnsCorrectAccountCreditAmount(Organization organization, + SutProvider sutProvider) + { + // Arrange + // Stripe reports balance in cents as a negative number for credit + const int stripeAccountBalance = -593; // $5.93 credit (negative cents) + const decimal creditAmount = 5.93M; // Same value in dollars + + + var customer = new Customer + { + Balance = stripeAccountBalance, + Subscriptions = new StripeList() + { + Data = + [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] + }, + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } + } + } + }; + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") + && options.Expand.Contains("subscriptions") + && options.Expand.Contains("tax_ids"))) + .Returns(customer); + + // Act + var result = await sutProvider.Sut.GetPaymentMethod(organization); + + // Assert + Assert.NotNull(result); + Assert.Equal(creditAmount, result.AccountCredit); + await sutProvider.GetDependency().Received(1).CustomerGetAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") && + options.Expand.Contains("subscriptions") && + options.Expand.Contains("tax_ids"))); + + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_WithZeroStripeAccountBalance_ReturnsCorrectAccountCreditAmount( + Organization organization, SutProvider sutProvider) + { + // Arrange + const int stripeAccountBalance = 0; + + var customer = new Customer + { + Balance = stripeAccountBalance, + Subscriptions = new StripeList() + { + Data = + [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] + }, + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } + } + } + }; + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") + && options.Expand.Contains("subscriptions") + && options.Expand.Contains("tax_ids"))) + .Returns(customer); + + // Act + var result = await sutProvider.Sut.GetPaymentMethod(organization); + + // Assert + Assert.NotNull(result); + Assert.Equal(0, result.AccountCredit); + await sutProvider.GetDependency().Received(1).CustomerGetAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") && + options.Expand.Contains("subscriptions") && + options.Expand.Contains("tax_ids"))); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_WithPositiveStripeAccountBalance_ReturnsCorrectAccountCreditAmount( + Organization organization, SutProvider sutProvider) + { + // Arrange + const int stripeAccountBalance = 593; // $5.93 charge balance + const decimal accountBalance = -5.93M; // account balance + var customer = new Customer + { + Balance = stripeAccountBalance, + Subscriptions = new StripeList() + { + Data = + [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] + }, + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } + } + } + }; + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") + && options.Expand.Contains("subscriptions") + && options.Expand.Contains("tax_ids"))) + .Returns(customer); + + // Act + var result = await sutProvider.Sut.GetPaymentMethod(organization); + + // Assert + Assert.NotNull(result); + Assert.Equal(accountBalance, result.AccountCredit); + await sutProvider.GetDependency().Received(1).CustomerGetAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") && + options.Expand.Contains("subscriptions") && + options.Expand.Contains("tax_ids"))); + + } + #endregion + + #region GetPaymentSource + + [Theory, BitAutoData] + public async Task GetPaymentSource_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentSource(null)); + + [Theory, BitAutoData] + public async Task GetPaymentSource_Braintree_NoDefaultPaymentMethod_ReturnsNull( Provider provider, SutProvider sutProvider) { @@ -372,7 +524,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Braintree_PayPalAccount_Succeeds( + public async Task GetPaymentSource_Braintree_PayPalAccount_Succeeds( Provider provider, SutProvider sutProvider) { @@ -421,7 +573,7 @@ public class SubscriberServiceTests // TODO: Determine if we need to test Braintree.UsBankAccount [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_BankAccountPaymentMethod_Succeeds( + public async Task GetPaymentSource_Stripe_BankAccountPaymentMethod_Succeeds( Provider provider, SutProvider sutProvider) { @@ -455,7 +607,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_CardPaymentMethod_Succeeds( + public async Task GetPaymentSource_Stripe_CardPaymentMethod_Succeeds( Provider provider, SutProvider sutProvider) { @@ -491,43 +643,37 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_SetupIntentForBankAccount_Succeeds( + public async Task GetPaymentSource_Stripe_SetupIntentForBankAccount_Succeeds( Provider provider, SutProvider sutProvider) { - var customer = new Customer - { - Id = provider.GatewayCustomerId - }; + var customer = new Customer { Id = provider.GatewayCustomerId }; sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, - Arg.Is( - options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method"))) + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains( + "invoice_settings.default_payment_method"))) .Returns(customer); var setupIntent = new SetupIntent { Id = "setup_intent_id", Status = "requires_action", - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, + NextAction = + new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, PaymentMethod = new PaymentMethod { - UsBankAccount = new PaymentMethodUsBankAccount - { - BankName = "Chase", - Last4 = "9999" - } + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } } }; sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); - sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, Arg.Is( - options => options.Expand.Contains("payment_method"))).Returns(setupIntent); + sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, + Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -537,24 +683,19 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_LegacyBankAccount_Succeeds( + public async Task GetPaymentSource_Stripe_LegacyBankAccount_Succeeds( Provider provider, SutProvider sutProvider) { var customer = new Customer { - DefaultSource = new BankAccount - { - Status = "verified", - BankName = "Chase", - Last4 = "9999" - } + DefaultSource = new BankAccount { Status = "verified", BankName = "Chase", Last4 = "9999" } }; sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, - Arg.Is( - options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method"))) + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains( + "invoice_settings.default_payment_method"))) .Returns(customer); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -565,25 +706,19 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_LegacyCard_Succeeds( + public async Task GetPaymentSource_Stripe_LegacyCard_Succeeds( Provider provider, SutProvider sutProvider) { var customer = new Customer { - DefaultSource = new Card - { - Brand = "Visa", - Last4 = "9999", - ExpMonth = 9, - ExpYear = 2028 - } + DefaultSource = new Card { Brand = "Visa", Last4 = "9999", ExpMonth = 9, ExpYear = 2028 } }; sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, - Arg.Is( - options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method"))) + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains( + "invoice_settings.default_payment_method"))) .Returns(customer); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -594,7 +729,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_LegacySourceCard_Succeeds( + public async Task GetPaymentSource_Stripe_LegacySourceCard_Succeeds( Provider provider, SutProvider sutProvider) { From 353b596a6d83eb010c0ab50cc35576943192784b Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:59:36 -0500 Subject: [PATCH 39/85] [PM-25390] CORS - Password Change URI (#6287) * enable cors headers for icons program - This is needed now that browsers can hit the change-password-uri path via API call * Add absolute route for change-password-uri --- src/Icons/Controllers/ChangePasswordUriController.cs | 2 +- src/Icons/Startup.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Icons/Controllers/ChangePasswordUriController.cs b/src/Icons/Controllers/ChangePasswordUriController.cs index 3f2bc91cf2..935cda77df 100644 --- a/src/Icons/Controllers/ChangePasswordUriController.cs +++ b/src/Icons/Controllers/ChangePasswordUriController.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Caching.Memory; namespace Bit.Icons.Controllers; -[Route("change-password-uri")] +[Route("~/change-password-uri")] public class ChangePasswordUriController : Controller { private readonly IMemoryCache _memoryCache; diff --git a/src/Icons/Startup.cs b/src/Icons/Startup.cs index 16bbdef553..2602dd6264 100644 --- a/src/Icons/Startup.cs +++ b/src/Icons/Startup.cs @@ -92,6 +92,9 @@ public class Startup await next(); }); + app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings)) + .AllowAnyMethod().AllowAnyHeader().AllowCredentials()); + app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); } From b7200837c3835c005ec3ae6a45b4a3ffa42d1c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Fri, 5 Sep 2025 19:54:49 +0200 Subject: [PATCH 40/85] [PM-25182] Improve Swagger OperationIDs for Billing (#6238) * Improve Swagger OperationIDs for Billing * Fix typo --- .../OrganizationSponsorshipsController.cs | 20 +++++++++++++++++-- ...elfHostedOrganizationLicensesController.cs | 4 ++-- ...ostedOrganizationSponsorshipsController.cs | 8 +++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 2d05595b2d..8c202752de 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -208,7 +208,6 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpDelete("{sponsoringOrganizationId}")] - [HttpPost("{sponsoringOrganizationId}/delete")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RevokeSponsorship(Guid sponsoringOrganizationId) { @@ -225,6 +224,15 @@ public class OrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpPost("{sponsoringOrganizationId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrganizationId} instead.")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostRevokeSponsorship(Guid sponsoringOrganizationId) + { + await RevokeSponsorship(sponsoringOrganizationId); + } + [Authorize("Application")] [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] [SelfHosted(NotSelfHostedOnly = true)] @@ -241,7 +249,6 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpDelete("sponsored/{sponsoredOrgId}")] - [HttpPost("sponsored/{sponsoredOrgId}/remove")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RemoveSponsorship(Guid sponsoredOrgId) { @@ -257,6 +264,15 @@ public class OrganizationSponsorshipsController : Controller await _removeSponsorshipCommand.RemoveSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpPost("sponsored/{sponsoredOrgId}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE /sponsored/{sponsoredOrgId} instead.")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostRemoveSponsorship(Guid sponsoredOrgId) + { + await RemoveSponsorship(sponsoredOrgId); + } + [HttpGet("{sponsoringOrgId}/sync-status")] public async Task GetSyncStatus(Guid sponsoringOrgId) { diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs index b4eecdba0f..147f2d52ee 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs @@ -53,7 +53,7 @@ public class SelfHostedOrganizationLicensesController : Controller } [HttpPost("")] - public async Task PostLicenseAsync(OrganizationCreateLicenseRequestModel model) + public async Task CreateLicenseAsync(OrganizationCreateLicenseRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -74,7 +74,7 @@ public class SelfHostedOrganizationLicensesController : Controller } [HttpPost("{id}")] - public async Task PostLicenseAsync(string id, LicenseRequestModel model) + public async Task UpdateLicenseAsync(string id, LicenseRequestModel model) { var orgIdGuid = new Guid(id); if (!await _currentContext.OrganizationOwner(orgIdGuid)) diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index de41a4cf10..198438201c 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -79,7 +79,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller } [HttpDelete("{sponsoringOrgId}")] - [HttpPost("{sponsoringOrgId}/delete")] public async Task RevokeSponsorship(Guid sponsoringOrgId) { var orgUser = await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); @@ -95,6 +94,13 @@ public class SelfHostedOrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [HttpPost("{sponsoringOrgId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrgId} instead.")] + public async Task PostRevokeSponsorship(Guid sponsoringOrgId) + { + await RevokeSponsorship(sponsoringOrgId); + } + [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) { From 2a01c804af1d4dd6e64ea08de11c05978c3e7ad9 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 8 Sep 2025 10:49:00 +0000 Subject: [PATCH 41/85] Bumped version to 2025.9.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 3af05be0f1..66fb49300c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.8.1 + 2025.9.0 Bit.$(MSBuildProjectName) enable From 7e50a46d3b315de379dfdd0c353f7f7cfd5a6c11 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:12:43 -0400 Subject: [PATCH 42/85] chore(feature-flag): Remove persist-popup-view feature flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 57798204ea..69003ee253 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -199,7 +199,6 @@ public static class FeatureFlagKeys public const string SendAccess = "pm-19394-send-access-control"; /* Platform Team */ - public const string PersistPopupView = "persist-popup-view"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; From 0fbbb6a984a84463e2912178e815a6b07528c9df Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:54:43 -0400 Subject: [PATCH 43/85] Event integration updates and cleanups (#6288) * Event integration updates and cleanups * Fix empty message on ArgumentException * Adjust exception message Co-authored-by: Matt Bishop --------- Co-authored-by: Matt Bishop --- .../Services/EventLoggingListenerService.cs | 4 +- .../Services/IEventMessageHandler.cs | 5 +- .../Services/IIntegrationHandler.cs | 55 +++++++++++++++++-- .../EventIntegrations/README.md | 12 +++- .../WebhookIntegrationHandler.cs | 46 ++-------------- .../Models/OrganizationIntegration.cs | 7 +-- .../OrganizationIntegrationConfiguration.cs | 7 +-- .../WebhookIntegrationHandlerTests.cs | 4 ++ 8 files changed, 76 insertions(+), 64 deletions(-) diff --git a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs index 53ff3d4d0a..84a862ce94 100644 --- a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs +++ b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs @@ -28,12 +28,12 @@ public abstract class EventLoggingListenerService : BackgroundService if (root.ValueKind == JsonValueKind.Array) { var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); + await _handler.HandleManyEventsAsync(eventMessages ?? throw new JsonException("Deserialize returned null")); } else if (root.ValueKind == JsonValueKind.Object) { var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); + await _handler.HandleEventAsync(eventMessage ?? throw new JsonException("Deserialize returned null")); } else { diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/AdminConsole/Services/IEventMessageHandler.cs index fcffb56c65..83c5e33ecb 100644 --- a/src/Core/AdminConsole/Services/IEventMessageHandler.cs +++ b/src/Core/AdminConsole/Services/IEventMessageHandler.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs index 9a3edac9ec..bb10dc01b9 100644 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -1,6 +1,5 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using System.Globalization; +using System.Net; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; @@ -20,8 +19,56 @@ public abstract class IntegrationHandlerBase : IIntegrationHandler public async Task HandleAsync(string json) { var message = IntegrationMessage.FromJson(json); - return await HandleAsync(message); + return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON")); } public abstract Task HandleAsync(IntegrationMessage message); + + protected IntegrationHandlerResult ResultFromHttpResponse( + HttpResponseMessage response, + IntegrationMessage message, + TimeProvider timeProvider) + { + var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); + + if (response.IsSuccessStatusCode) return result; + + switch (response.StatusCode) + { + case HttpStatusCode.TooManyRequests: + case HttpStatusCode.RequestTimeout: + case HttpStatusCode.InternalServerError: + case HttpStatusCode.BadGateway: + case HttpStatusCode.ServiceUnavailable: + case HttpStatusCode.GatewayTimeout: + result.Retryable = true; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; + + if (response.Headers.TryGetValues("Retry-After", out var values)) + { + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var seconds)) + { + // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. + result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; + } + else if (DateTimeOffset.TryParseExact(value, + "r", // "r" is the round-trip format: RFC1123 + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var retryDate)) + { + // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. + result.DelayUntilDate = retryDate.UtcDateTime; + } + } + break; + default: + result.Retryable = false; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; + break; + } + + return result; + } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 83b59cdec1..4092cc20ad 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -418,13 +418,21 @@ dependencies and integrations. For instance, `SlackIntegrationHandler` needs a ` `AddEventIntegrationServices` has a call to `AddSlackService`. Same thing for webhooks when it comes to defining a custom HttpClient by name. -1. In `AddEventIntegrationServices` create the listener configuration: +In `AddEventIntegrationServices`: + +1. Create the singleton for the handler: + +``` csharp + services.TryAddSingleton, ExampleIntegrationHandler>(); +``` + +2. Create the listener configuration: ``` csharp var exampleConfiguration = new ExampleListenerConfiguration(globalSettings); ``` -2. Add the integration to both the RabbitMQ and ASB specific declarations: +3. Add the integration to both the RabbitMQ and ASB specific declarations: ``` csharp services.AddRabbitMqIntegration(exampleConfiguration); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index 99cad65efa..e0c2b66a90 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -1,7 +1,5 @@ #nullable enable -using System.Globalization; -using System.Net; using System.Net.Http.Headers; using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; @@ -17,7 +15,8 @@ public class WebhookIntegrationHandler( public const string HttpClientName = "WebhookIntegrationHandlerHttpClient"; - public override async Task HandleAsync(IntegrationMessage message) + public override async Task HandleAsync( + IntegrationMessage message) { var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); @@ -28,45 +27,8 @@ public class WebhookIntegrationHandler( parameter: message.Configuration.Token ); } + var response = await _httpClient.SendAsync(request); - var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); - - switch (response.StatusCode) - { - case HttpStatusCode.TooManyRequests: - case HttpStatusCode.RequestTimeout: - case HttpStatusCode.InternalServerError: - case HttpStatusCode.BadGateway: - case HttpStatusCode.ServiceUnavailable: - case HttpStatusCode.GatewayTimeout: - result.Retryable = true; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; - - if (response.Headers.TryGetValues("Retry-After", out var values)) - { - var value = values.FirstOrDefault(); - if (int.TryParse(value, out var seconds)) - { - // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; - } - else if (DateTimeOffset.TryParseExact(value, - "r", // "r" is the round-trip format: RFC1123 - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var retryDate)) - { - // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. - result.DelayUntilDate = retryDate.UtcDateTime; - } - } - break; - default: - result.Retryable = false; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; - break; - } - - return result; + return ResultFromHttpResponse(response, message, timeProvider); } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs index 5e5f7d4802..0f47d5947b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs @@ -1,13 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AutoMapper; +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class OrganizationIntegration : Core.AdminConsole.Entities.OrganizationIntegration { - public virtual Organization Organization { get; set; } + public virtual required Organization Organization { get; set; } } public class OrganizationIntegrationMapperProfile : Profile diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs index 52b8783fcf..21b282f767 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs @@ -1,13 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AutoMapper; +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class OrganizationIntegrationConfiguration : Core.AdminConsole.Entities.OrganizationIntegrationConfiguration { - public virtual OrganizationIntegration OrganizationIntegration { get; set; } + public virtual required OrganizationIntegration OrganizationIntegration { get; set; } } public class OrganizationIntegrationConfigurationMapperProfile : Profile diff --git a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs index bf4283243c..53a3598d47 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs @@ -51,6 +51,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); + Assert.Empty(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) @@ -59,6 +60,7 @@ public class WebhookIntegrationHandlerTests Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); + Assert.NotNull(request.Content); var returned = await request.Content.ReadAsStringAsync(); Assert.Equal(HttpMethod.Post, request.Method); @@ -77,6 +79,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); + Assert.Empty(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) @@ -85,6 +88,7 @@ public class WebhookIntegrationHandlerTests Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); + Assert.NotNull(request.Content); var returned = await request.Content.ReadAsStringAsync(); Assert.Equal(HttpMethod.Post, request.Method); From 39ad02041870dc67150937b79043f110d44d60fd Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:23:08 -0700 Subject: [PATCH 44/85] [PM-22219] - [Vault] [Server] Exclude items in default collections from Admin Console (#5992) * add GetAllOrganizationCiphersExcludingDefaultUserCollections * add sproc * update sproc and feature flag name * add sproc. update tests * rename sproc * rename sproc * use single sproc * revert change * remove unused code. update sproc * remove joins from proc * update migration filename * fix syntax * fix indentation * remove unnecessary feature flag and go statements. clean up code * update sproc, view, and index * update sproc * update index * update timestamp * update filename. update sproc to match EF filter * match only enabled organizations. make index creation idempotent * update file timestamp * update timestamp * use square brackets * add square brackets * formatting fixes * rename view * remove index --- .../Vault/Controllers/CiphersController.cs | 12 +++- .../Queries/IOrganizationCiphersQuery.cs | 6 ++ .../Vault/Queries/OrganizationCiphersQuery.cs | 6 ++ .../Vault/Repositories/ICipherRepository.cs | 6 ++ .../Vault/Repositories/CipherRepository.cs | 42 +++++++++++ .../Vault/Repositories/CipherRepository.cs | 50 ++++++++++++++ ...anizationIdExcludingDefaultCollections.sql | 39 +++++++++++ src/Sql/dbo/Vault/Tables/Cipher.sql | 1 + ...ganizationCipherDetailsCollectionsView.sql | 28 ++++++++ .../Queries/OrganizationCiphersQueryTests.cs | 43 ++++++++++++ ...tionDetailsExcludingDefaultCollections.sql | 69 +++++++++++++++++++ 11 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql create mode 100644 src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql create mode 100644 util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 761a5a3726..84e0488e5a 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -48,6 +48,7 @@ public class CiphersController : Controller private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; + private readonly IFeatureService _featureService; public CiphersController( ICipherRepository cipherRepository, @@ -61,7 +62,8 @@ public class CiphersController : Controller GlobalSettings globalSettings, IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IFeatureService featureService) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -75,6 +77,7 @@ public class CiphersController : Controller _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; + _featureService = featureService; } [HttpGet("{id}")] @@ -314,8 +317,11 @@ public class CiphersController : Controller { throw new NotFoundException(); } - - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + var allOrganizationCiphers = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + ? + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) + : + await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); var allOrganizationCipherResponses = allOrganizationCiphers.Select(c => diff --git a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs index 1756cad3c7..44a56eac48 100644 --- a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs @@ -37,4 +37,10 @@ public interface IOrganizationCiphersQuery /// public Task> GetOrganizationCiphersByCollectionIds( Guid organizationId, IEnumerable collectionIds); + + /// + /// Returns all organization ciphers except those in default user collections. + /// + public Task> + GetAllOrganizationCiphersExcludingDefaultUserCollections(Guid organizationId); } diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index deed121216..945fdb7e3c 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -61,4 +61,10 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var allOrganizationCiphers = await GetAllOrganizationCiphers(organizationId); return allOrganizationCiphers.Where(c => c.CollectionIds.Intersect(managedCollectionIds).Any()); } + + public async Task> + GetAllOrganizationCiphersExcludingDefaultUserCollections(Guid orgId) + { + return (await _cipherRepository.GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(orgId)).ToList(); + } } diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index 60b6e21f1d..e442477921 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -84,6 +84,12 @@ public interface ICipherRepository : IRepository /// A list of ciphers with updated data UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable ciphers); + + /// + /// Returns all ciphers belonging to the organization excluding those with default collections + /// + Task> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId); /// /// /// This version uses the bulk resource creation service to create the temp table. diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 8c1f04affc..08593191f1 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -7,6 +7,7 @@ using Bit.Core.Entities; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; @@ -867,6 +868,47 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid orgId) + { + await using var connection = new SqlConnection(ConnectionString); + + var dict = new Dictionary(); + var tempCollections = new Dictionary>(); + + await connection.QueryAsync( + $"[{Schema}].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections]", + (cipher, cc) => + { + if (!dict.TryGetValue(cipher.Id, out var details)) + { + details = new CipherOrganizationDetailsWithCollections(cipher, /*dummy*/null); + dict.Add(cipher.Id, details); + tempCollections[cipher.Id] = new List(); + } + + if (cc?.CollectionId != null) + { + tempCollections[cipher.Id].AddIfNotExists(cc.CollectionId); + } + + return details; + }, + new { OrganizationId = orgId }, + splitOn: "CollectionId", + commandType: CommandType.StoredProcedure + ); + + // now assign each List back to the array property in one shot + foreach (var kv in dict) + { + kv.Value.CollectionIds = tempCollections[kv.Key].ToArray(); + } + + return dict.Values.ToList(); + } + + private DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable ciphers) { var c = ciphers.FirstOrDefault(); diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index d595fe7cfe..1a137c5f4b 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using AutoMapper; +using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Utilities; using Bit.Core.Vault.Enums; @@ -1001,6 +1002,55 @@ public class CipherRepository : Repository> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var defaultTypeInt = (int)CollectionType.DefaultUserCollection; + + // filter out any cipher that belongs *only* to default collections + // i.e. keep ciphers with no collections, or with ≥1 non-default collection + var query = from c in dbContext.Ciphers.AsNoTracking() + where c.UserId == null + && c.OrganizationId == organizationId + && c.Organization.Enabled + && ( + c.CollectionCiphers.Count() == 0 + || c.CollectionCiphers.Any(cc => (int)cc.Collection.Type != defaultTypeInt) + ) + select new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails + { + Id = c.Id, + UserId = c.UserId, + OrganizationId = c.OrganizationId, + Type = c.Type, + Data = c.Data, + Favorites = c.Favorites, + Folders = c.Folders, + Attachments = c.Attachments, + CreationDate = c.CreationDate, + RevisionDate = c.RevisionDate, + DeletedDate = c.DeletedDate, + Reprompt = c.Reprompt, + Key = c.Key, + OrganizationUseTotp = c.Organization.UseTotp + }, + new Dictionary>() + ) + { + CollectionIds = c.CollectionCiphers + .Where(cc => (int)cc.Collection.Type != defaultTypeInt) + .Select(cc => cc.CollectionId) + .ToArray() + }; + + var result = await query.ToListAsync(); + return result; + } + /// /// /// EF does not use the bulk resource creation service, so we need to use the regular update method. diff --git a/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql b/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql new file mode 100644 index 0000000000..c678386f8a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql @@ -0,0 +1,39 @@ + -- Stored procedure that filters out ciphers that ONLY belong to default collections +CREATE PROCEDURE + [dbo].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + WITH [NonDefaultCiphers] AS ( + SELECT DISTINCT [Id] + FROM [dbo].[OrganizationCipherDetailsCollectionsView] + WHERE [OrganizationId] = @OrganizationId + AND ([CollectionId] IS NULL + OR [CollectionType] <> 1) + ) + + SELECT + V.[Id], + V.[UserId], + V.[OrganizationId], + V.[Type], + V.[Data], + V.[Favorites], + V.[Folders], + V.[Attachments], + V.[CreationDate], + V.[RevisionDate], + V.[DeletedDate], + V.[Reprompt], + V.[Key], + V.[OrganizationUseTotp], + V.[CollectionId] -- For Dapper splitOn parameter + FROM [dbo].[OrganizationCipherDetailsCollectionsView] V + INNER JOIN [NonDefaultCiphers] NDC ON V.[Id] = NDC.[Id] + WHERE V.[OrganizationId] = @OrganizationId + AND (V.[CollectionId] IS NULL OR V.[CollectionType] <> 1) + ORDER BY V.[RevisionDate] DESC; + END; + GO \ No newline at end of file diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql index 5ecff19e70..38dd47d21f 100644 --- a/src/Sql/dbo/Vault/Tables/Cipher.sql +++ b/src/Sql/dbo/Vault/Tables/Cipher.sql @@ -34,3 +34,4 @@ GO CREATE NONCLUSTERED INDEX [IX_Cipher_DeletedDate] ON [dbo].[Cipher]([DeletedDate] ASC); +GO diff --git a/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql b/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql new file mode 100644 index 0000000000..66bb38fe10 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql @@ -0,0 +1,28 @@ +CREATE VIEW [dbo].[OrganizationCipherDetailsCollectionsView] +AS + SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[Favorites], + C.[Folders], + C.[CreationDate], + C.[RevisionDate], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + CASE + WHEN O.[UseTotp] = 1 THEN 1 + ELSE 0 + END AS [OrganizationUseTotp], + CC.[CollectionId], + COL.[Type] AS [CollectionType] + FROM [dbo].[Cipher] C + INNER JOIN [dbo].[Organization] O ON C.[OrganizationId] = O.[Id] + LEFT JOIN [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] + LEFT JOIN [dbo].[Collection] COL ON CC.[CollectionId] = COL.[Id] + WHERE C.[UserId] IS NULL -- Organization ciphers only + AND O.[Enabled] = 1; -- Only enabled organizations diff --git a/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs b/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs index 01539fe7d7..0d7443354e 100644 --- a/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs +++ b/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs @@ -89,4 +89,47 @@ public class OrganizationCiphersQueryTests c.CollectionIds.Any(cId => cId == targetCollectionId) && c.CollectionIds.Any(cId => cId == otherCollectionId)); } + + + [Theory, BitAutoData] + public async Task GetAllOrganizationCiphersExcludingDefaultUserCollections_DelegatesToRepository( + Guid organizationId, + SutProvider sutProvider) + { + var item1 = new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails { Id = Guid.NewGuid(), OrganizationId = organizationId }, + new Dictionary>()); + var item2 = new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails { Id = Guid.NewGuid(), OrganizationId = organizationId }, + new Dictionary>()); + + var repo = sutProvider.GetDependency(); + repo.GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(organizationId) + .Returns(Task.FromResult>( + new[] { item1, item2 })); + + var actual = (await sutProvider.Sut + .GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)) + .ToList(); + + Assert.Equal(2, actual.Count); + Assert.Same(item1, actual[0]); + Assert.Same(item2, actual[1]); + + // and we indeed called the repo once + await repo.Received(1) + .GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(organizationId); + } + + private CipherOrganizationDetailsWithCollections MakeWith( + CipherOrganizationDetails baseCipher, + params Guid[] cols) + { + var dict = cols + .Select(cid => new CollectionCipher { CipherId = baseCipher.Id, CollectionId = cid }) + .GroupBy(cc => cc.CipherId) + .ToDictionary(g => g.Key, g => g); + + return new CipherOrganizationDetailsWithCollections(baseCipher, dict); + } } diff --git a/util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql b/util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql new file mode 100644 index 0000000000..a7dfa2f7d7 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql @@ -0,0 +1,69 @@ +-- View that provides organization cipher details with their collection associations +CREATE OR ALTER VIEW [dbo].[OrganizationCipherDetailsCollectionsView] +AS + SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[Favorites], + C.[Folders], + C.[CreationDate], + C.[RevisionDate], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + CASE + WHEN O.[UseTotp] = 1 THEN 1 + ELSE 0 + END AS [OrganizationUseTotp], + CC.[CollectionId], + COL.[Type] AS [CollectionType] + FROM [dbo].[Cipher] C + INNER JOIN [dbo].[Organization] O ON C.[OrganizationId] = O.[Id] + LEFT JOIN [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] + LEFT JOIN [dbo].[Collection] COL ON CC.[CollectionId] = COL.[Id] + WHERE C.[UserId] IS NULL -- Organization ciphers only + AND O.[Enabled] = 1; -- Only enabled organizations +GO + + -- Stored procedure that filters out ciphers that ONLY belong to default collections +CREATE OR ALTER PROCEDURE + [dbo].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + WITH [NonDefaultCiphers] AS ( + SELECT DISTINCT [Id] + FROM [dbo].[OrganizationCipherDetailsCollectionsView] + WHERE [OrganizationId] = @OrganizationId + AND ([CollectionId] IS NULL OR [CollectionType] <> 1) + ) + + SELECT + V.[Id], + V.[UserId], + V.[OrganizationId], + V.[Type], + V.[Data], + V.[Favorites], + V.[Folders], + V.[Attachments], + V.[CreationDate], + V.[RevisionDate], + V.[DeletedDate], + V.[Reprompt], + V.[Key], + V.[OrganizationUseTotp], + V.[CollectionId] -- For Dapper splitOn parameter + FROM [dbo].[OrganizationCipherDetailsCollectionsView] V + INNER JOIN [NonDefaultCiphers] NDC ON V.[Id] = NDC.[Id] + WHERE V.[OrganizationId] = @OrganizationId + AND (V.[CollectionId] IS NULL OR V.[CollectionType] <> 1) + ORDER BY V.[RevisionDate] DESC; + END; +GO From 747e212b1ba374e4c1be9cfe255bb40c002d0ceb Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:39:59 -0400 Subject: [PATCH 45/85] Add Datadog integration (#6289) * Event integration updates and cleanups * Add Datadog integration * Update README to include link to Datadog PR * Move doc update into the Datadog PR; Fix empty message on ArgumentException * Adjust exception message Co-authored-by: Matt Bishop * Removed unnecessary nullable enable; Moved Docs link to PR into this PR * Remove unnecessary nullable enable calls --------- Co-authored-by: Matt Bishop --- dev/servicebusemulator_config.json | 17 ++ ...ionIntegrationConfigurationRequestModel.cs | 8 +- .../OrgnizationIntegrationRequestModel.cs | 10 +- .../AdminConsole/Enums/IntegrationType.cs | 5 +- .../EventIntegrations/DatadogIntegration.cs | 3 + .../DatadogIntegrationConfigurationDetails.cs | 3 + .../DatadogListenerConfiguration.cs | 38 +++++ .../DatadogIntegrationHandler.cs | 25 +++ .../EventIntegrations/README.md | 3 +- src/Core/Settings/GlobalSettings.cs | 5 + .../Utilities/ServiceCollectionExtensions.cs | 5 + ...tegrationConfigurationRequestModelTests.cs | 26 +++ ...rganizationIntegrationRequestModelTests.cs | 48 ++++++ .../DatadogIntegrationHandlerTests.cs | 157 ++++++++++++++++++ 14 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs create mode 100644 test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index dcf48b7a8c..294efc1897 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -34,6 +34,9 @@ }, { "Name": "events-hec-subscription" + }, + { + "Name": "events-datadog-subscription" } ] }, @@ -81,6 +84,20 @@ } } ] + }, + { + "Name": "integration-datadog-subscription", + "Rules": [ + { + "Name": "datadog-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "datadog" + } + } + } + ] } ] } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 17e116b8d1..7d1efe2315 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; @@ -36,6 +34,10 @@ public class OrganizationIntegrationConfigurationRequestModel return !string.IsNullOrWhiteSpace(Template) && Configuration is null && IsFiltersValid(); + case IntegrationType.Datadog: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); default: return false; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index edae0719e3..5fa2e86a90 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -4,8 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModel : IValidatableObject @@ -60,6 +58,14 @@ public class OrganizationIntegrationRequestModel : IValidatableObject new[] { nameof(Configuration) }); } break; + case IntegrationType.Datadog: + if (!IsIntegrationValid()) + { + yield return new ValidationResult( + "Datadog integrations must include valid configuration.", + new[] { nameof(Configuration) }); + } + break; default: yield return new ValidationResult( $"Integration type '{Type}' is not recognized.", diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 58e55193dc..34edc71fbe 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -6,7 +6,8 @@ public enum IntegrationType : int Scim = 2, Slack = 3, Webhook = 4, - Hec = 5 + Hec = 5, + Datadog = 6 } public static class IntegrationTypeExtensions @@ -21,6 +22,8 @@ public static class IntegrationTypeExtensions return "webhook"; case IntegrationType.Hec: return "hec"; + case IntegrationType.Datadog: + return "datadog"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs new file mode 100644 index 0000000000..8785a74896 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record DatadogIntegration(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..07aafa4bd8 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs new file mode 100644 index 0000000000..1c74826791 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class DatadogListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Datadog; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.DatadogEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.DatadogIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs new file mode 100644 index 0000000000..45bb5b6d7d --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs @@ -0,0 +1,25 @@ +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +namespace Bit.Core.Services; + +public class DatadogIntegrationHandler( + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider) + : IntegrationHandlerBase +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public const string HttpClientName = "DatadogIntegrationHandlerHttpClient"; + + public override async Task HandleAsync(IntegrationMessage message) + { + var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); + request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); + request.Headers.Add("DD-API-KEY", message.Configuration.ApiKey); + + var response = await _httpClient.SendAsync(request); + + return ResultFromHttpResponse(response, message, timeProvider); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 4092cc20ad..de7ce3f7fd 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -323,7 +323,8 @@ A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the bac # Building a new integration These are all the pieces required in the process of building out a new integration. For -clarity in naming, these assume a new integration called "Example". +clarity in naming, these assume a new integration called "Example". To see a complete example +in context, view [the PR for adding the Datadog integration](https://github.com/bitwarden/server/pull/6289). ## IntegrationType diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index d6e18a4c81..638e1477c1 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -304,6 +304,8 @@ public class GlobalSettings : IGlobalSettings public virtual string WebhookIntegrationSubscriptionName { get; set; } = "integration-webhook-subscription"; public virtual string HecEventSubscriptionName { get; set; } = "events-hec-subscription"; public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription"; + public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription"; + public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription"; public string ConnectionString { @@ -345,6 +347,9 @@ public class GlobalSettings : IGlobalSettings public virtual string HecEventsQueueName { get; set; } = "events-hec-queue"; public virtual string HecIntegrationQueueName { get; set; } = "integration-hec-queue"; public virtual string HecIntegrationRetryQueueName { get; set; } = "integration-hec-retry-queue"; + public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue"; + public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue"; + public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue"; public string HostName { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 4f0d0d4397..592f7c84c3 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -881,15 +881,18 @@ public static class ServiceCollectionExtensions services.AddSlackService(globalSettings); services.TryAddSingleton(TimeProvider.System); services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); + services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); // Add integration handlers services.TryAddSingleton, SlackIntegrationHandler>(); services.TryAddSingleton, WebhookIntegrationHandler>(); + services.TryAddSingleton, DatadogIntegrationHandler>(); var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); var slackConfiguration = new SlackListenerConfiguration(globalSettings); var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); var hecConfiguration = new HecListenerConfiguration(globalSettings); + var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); if (IsRabbitMqEnabled(globalSettings)) { @@ -906,6 +909,7 @@ public static class ServiceCollectionExtensions services.AddRabbitMqIntegration(slackConfiguration); services.AddRabbitMqIntegration(webhookConfiguration); services.AddRabbitMqIntegration(hecConfiguration); + services.AddRabbitMqIntegration(datadogConfiguration); } if (IsAzureServiceBusEnabled(globalSettings)) @@ -923,6 +927,7 @@ public static class ServiceCollectionExtensions services.AddAzureServiceBusIntegration(slackConfiguration); services.AddAzureServiceBusIntegration(webhookConfiguration); services.AddAzureServiceBusIntegration(hecConfiguration); + services.AddAzureServiceBusIntegration(datadogConfiguration); } return services; diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs index 6af5b8039b..74fe75a9d7 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -62,6 +62,32 @@ public class OrganizationIntegrationConfigurationRequestModelTests Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); } + [Theory] + [InlineData(data: "")] + [InlineData(data: " ")] + public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config) + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + } + + [Fact] + public void IsValidForType_NullDatadogConfiguration_ReturnsTrue() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = null, + Template = "template" + }; + + Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); + } + [Theory] [InlineData(data: null)] [InlineData(data: "")] diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 147564dd94..9565a76822 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -147,6 +147,54 @@ public class OrganizationIntegrationRequestModelTests Assert.Empty(results); } + [Fact] + public void Validate_Datadog_WithNullConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("must include valid configuration", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Datadog_WithInvalidConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = "Not valid" + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("must include valid configuration", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Datadog_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = JsonSerializer.Serialize( + new DatadogIntegration(ApiKey: "API1234", Uri: new Uri("http://localhost")) + ) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); + } + [Fact] public void Validate_UnknownIntegrationType_ReturnsUnrecognizedError() { diff --git a/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs new file mode 100644 index 0000000000..5f0a9915bf --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs @@ -0,0 +1,157 @@ +#nullable enable + +using System.Net; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Bit.Test.Common.MockedHttpClient; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class DatadogIntegrationHandlerTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + private const string _apiKey = "AUTH_TOKEN"; + private static readonly Uri _datadogUri = new Uri("https://localhost"); + + public DatadogIntegrationHandlerTests() + { + _handler = new MockedHttpMessageHandler(); + _handler.Fallback + .WithStatusCode(HttpStatusCode.OK) + .WithContent(new StringContent("testtest")); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(DatadogIntegrationHandler.HttpClientName).Returns(_httpClient); + + return new SutProvider() + .SetDependency(clientFactory) + .WithFakeTimeProvider() + .Create(); + } + + [Theory, BitAutoData] + public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.True(result.Success); + Assert.Equal(result.Message, message); + Assert.Empty(result.FailureReason); + + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName)) + ); + + Assert.Single(_handler.CapturedRequests); + var request = _handler.CapturedRequests[0]; + Assert.NotNull(request); + Assert.NotNull(request.Content); + var returned = await request.Content.ReadAsStringAsync(); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_apiKey, request.Headers.GetValues("DD-API-KEY").Single()); + Assert.Equal(_datadogUri, request.RequestUri); + AssertHelper.AssertPropertyEqual(message.RenderedTemplate, returned); + } + + [Theory, BitAutoData] + public async Task HandleAsync_TooManyRequests_ReturnsFailureSetsDelayUntilDate(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc); + var retryAfter = now.AddSeconds(60); + + sutProvider.GetDependency().SetUtcNow(now); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.TooManyRequests) + .WithHeader("Retry-After", "60") + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + Assert.True(result.DelayUntilDate.HasValue); + Assert.Equal(retryAfter, result.DelayUntilDate.Value); + Assert.Equal("Too Many Requests", result.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleAsync_TooManyRequestsWithDate_ReturnsFailureSetsDelayUntilDate(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc); + var retryAfter = now.AddSeconds(60); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.TooManyRequests) + .WithHeader("Retry-After", retryAfter.ToString("r")) + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + Assert.True(result.DelayUntilDate.HasValue); + Assert.Equal(retryAfter, result.DelayUntilDate.Value); + Assert.Equal("Too Many Requests", result.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleAsync_InternalServerError_ReturnsFailureSetsRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.InternalServerError) + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + Assert.False(result.DelayUntilDate.HasValue); + Assert.Equal("Internal Server Error", result.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleAsync_UnexpectedRedirect_ReturnsFailureNotRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.TemporaryRedirect) + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + Assert.Null(result.DelayUntilDate); + Assert.Equal("Temporary Redirect", result.FailureReason); + } +} From cb0d5a5ba60b5da47bf14b4bbf6d87313f75bc0a Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 8 Sep 2025 19:45:06 +0000 Subject: [PATCH 46/85] Bumped version to 2025.9.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 66fb49300c..9038c8d95d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.0 + 2025.9.1 Bit.$(MSBuildProjectName) enable From 226f274a7237e4c2811fd82ecb409c650f525d3d Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Mon, 8 Sep 2025 15:06:13 -0500 Subject: [PATCH 47/85] Organization report tables, repos, services, and endpoints (#6158) * PM-23754 initial commit * pm-23754 fixing controller tests * pm-23754 adding commands and queries * pm-23754 adding endpoints, command/queries, repositories, and sql migrations * pm-23754 add new sql scripts * PM-23754 adding sql scripts * pm-23754 * PM-23754 fixing migration script * PM-23754 fixing migration script again * PM-23754 fixing migration script validation * PM-23754 fixing db validation script issue * PM-23754 fixing endpoint and db validation * PM-23754 fixing unit tests * PM-23754 fixing implementation based on comments and tests * PM-23754 updating logging statements * PM-23754 making changes based on PR comments. * updating migration scripts * removing old migration files * update code based testing for whole data object for OrganizationReport and add a stored procedure. * updating services, unit tests, repository tests * fixing unit tests * fixing migration script * fixing migration script again * fixing migration script * another fix * fixing sql file, updating controller to account for different orgIds in the url and body. * updating error message in controllers without a body * making a change to the command * Refactor ReportsController by removing organization reports The IDropOrganizationReportCommand is no longer needed * will code based on PR comments. * fixing unit test * fixing migration script based on last changes. * adding another check in endpoint and adding unit tests * fixing route parameter. * PM-23754 updating data fields to return just the column * PM-23754 fixing repository method signatures * PM-23754 making change to orgId parameter through out code to align with api naming --------- Co-authored-by: Tom <144813356+ttalty@users.noreply.github.com> --- .../OrganizationReportsController.cs | 297 ++ src/Api/Dirt/Controllers/ReportsController.cs | 194 - src/Core/Dirt/Entities/OrganizationReport.cs | 5 +- ...ganizationReportApplicationDataResponse.cs | 6 + .../Data/OrganizationReportDataResponse.cs | 6 + .../OrganizationReportSummaryDataResponse.cs | 6 + .../AddOrganizationReportCommand.cs | 27 +- .../DropOrganizationReportCommand.cs | 45 - ...tOrganizationReportApplicationDataQuery.cs | 62 + .../GetOrganizationReportDataQuery.cs | 62 + .../GetOrganizationReportQuery.cs | 20 +- ...zationReportSummaryDataByDateRangeQuery.cs | 89 + .../GetOrganizationReportSummaryDataQuery.cs | 62 + .../IDropOrganizationReportCommand.cs | 9 - ...tOrganizationReportApplicationDataQuery.cs | 8 + .../IGetOrganizationReportDataQuery.cs | 8 + .../Interfaces/IGetOrganizationReportQuery.cs | 2 +- ...zationReportSummaryDataByDateRangeQuery.cs | 9 + .../IGetOrganizationReportSummaryDataQuery.cs | 8 + ...rganizationReportApplicationDataCommand.cs | 9 + .../IUpdateOrganizationReportCommand.cs | 9 + .../IUpdateOrganizationReportDataCommand.cs | 9 + ...IUpdateOrganizationReportSummaryCommand.cs | 9 + .../ReportingServiceCollectionExtensions.cs | 9 +- .../Requests/AddOrganizationReportRequest.cs | 7 +- ...tionReportSummaryDataByDateRangeRequest.cs | 11 + ...rganizationReportApplicationDataRequest.cs | 11 + .../UpdateOrganizationReportDataRequest.cs | 11 + .../UpdateOrganizationReportRequest.cs | 14 + .../UpdateOrganizationReportSummaryRequest.cs | 11 + ...rganizationReportApplicationDataCommand.cs | 96 + .../UpdateOrganizationReportCommand.cs | 124 + .../UpdateOrganizationReportDataCommand.cs | 96 + .../UpdateOrganizationReportSummaryCommand.cs | 96 + .../IOrganizationReportRepository.cs | 17 +- .../Dirt/OrganizationReportRepository.cs | 148 +- .../OrganizationReportRepository.cs | 166 +- .../OrganizationReport_Create.sql | 53 +- ...anizationReport_GetApplicationDataById.sql | 12 + ...zationReport_GetLatestByOrganizationId.sql | 19 + .../OrganizationReport_GetReportDataById.sql | 12 + ...nizationReport_GetSummariesByDateRange.sql | 17 + .../OrganizationReport_GetSummaryDataById.sql | 13 + ...rganizationReport_ReadByOrganizationId.sql | 9 - .../OrganizationReport_Update.sql | 23 + ...ganizationReport_UpdateApplicationData.sql | 16 + .../OrganizationReport_UpdateReportData.sql | 16 + .../OrganizationReport_UpdateSummaryData.sql | 16 + .../dbo/Dirt/Tables/OrganizationReport.sql | 15 +- .../OrganizationReportsControllerTests.cs | 1165 ++++++ test/Api.Test/Dirt/ReportsControllerTests.cs | 321 -- .../DeleteOrganizationReportCommandTests.cs | 194 - ...nizationReportApplicationDataQueryTests.cs | 116 + .../GetOrganizationReportDataQueryTests.cs | 116 + .../GetOrganizationReportQueryTests.cs | 188 - ...nReportSummaryDataByDateRangeQueryTests.cs | 133 + ...OrganizationReportSummaryDataQueryTests.cs | 116 + ...zationReportApplicationDataCommandTests.cs | 252 ++ .../UpdateOrganizationReportCommandTests.cs | 230 ++ ...pdateOrganizationReportDataCommandTests.cs | 252 ++ ...teOrganizationReportSummaryCommandTests.cs | 252 ++ .../OrganizationReportRepositoryTests.cs | 323 +- .../2025-08-22_00_AlterOrganizationReport.sql | 96 + ..._AddOrganizationReportStoredProcedures.sql | 156 + ...-22_00_AlterOrganizationReport.Designer.cs | 3275 ++++++++++++++++ ...9_2025-08-22_00_AlterOrganizationReport.cs | 49 + ...nizationReportStoredProcedures.Designer.cs | 3275 ++++++++++++++++ ...1_AddOrganizationReportStoredProcedures.cs | 21 + .../DatabaseContextModelSnapshot.cs | 12 +- ...-22_00_AlterOrganizationReport.Designer.cs | 3281 +++++++++++++++++ ...0_2025-08-22_00_AlterOrganizationReport.cs | 47 + ...nizationReportStoredProcedures.Designer.cs | 3281 +++++++++++++++++ ...1_AddOrganizationReportStoredProcedures.cs | 21 + .../DatabaseContextModelSnapshot.cs | 12 +- ...-22_00_AlterOrganizationReport.Designer.cs | 3264 ++++++++++++++++ ...5_2025-08-22_00_AlterOrganizationReport.cs | 47 + ...nizationReportStoredProcedures.Designer.cs | 3264 ++++++++++++++++ ...1_AddOrganizationReportStoredProcedures.cs | 21 + .../DatabaseContextModelSnapshot.cs | 12 +- 79 files changed, 24744 insertions(+), 1047 deletions(-) create mode 100644 src/Api/Dirt/Controllers/OrganizationReportsController.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql delete mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql create mode 100644 test/Api.Test/Dirt/OrganizationReportsControllerTests.cs delete mode 100644 test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs delete mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs create mode 100644 util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql create mode 100644 util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql create mode 100644 util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs create mode 100644 util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs create mode 100644 util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs create mode 100644 util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs create mode 100644 util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs create mode 100644 util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs new file mode 100644 index 0000000000..bcd64b0bdf --- /dev/null +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -0,0 +1,297 @@ +using Bit.Core.Context; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Dirt.Controllers; + +[Route("reports/organizations")] +[Authorize("Application")] +public class OrganizationReportsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; + private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; + private readonly IUpdateOrganizationReportSummaryCommand _updateOrganizationReportSummaryCommand; + private readonly IGetOrganizationReportSummaryDataQuery _getOrganizationReportSummaryDataQuery; + private readonly IGetOrganizationReportSummaryDataByDateRangeQuery _getOrganizationReportSummaryDataByDateRangeQuery; + private readonly IGetOrganizationReportDataQuery _getOrganizationReportDataQuery; + private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand; + private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery; + private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand; + + public OrganizationReportsController( + ICurrentContext currentContext, + IGetOrganizationReportQuery getOrganizationReportQuery, + IAddOrganizationReportCommand addOrganizationReportCommand, + IUpdateOrganizationReportCommand updateOrganizationReportCommand, + IUpdateOrganizationReportSummaryCommand updateOrganizationReportSummaryCommand, + IGetOrganizationReportSummaryDataQuery getOrganizationReportSummaryDataQuery, + IGetOrganizationReportSummaryDataByDateRangeQuery getOrganizationReportSummaryDataByDateRangeQuery, + IGetOrganizationReportDataQuery getOrganizationReportDataQuery, + IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand, + IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery, + IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand + ) + { + _currentContext = currentContext; + _getOrganizationReportQuery = getOrganizationReportQuery; + _addOrganizationReportCommand = addOrganizationReportCommand; + _updateOrganizationReportCommand = updateOrganizationReportCommand; + _updateOrganizationReportSummaryCommand = updateOrganizationReportSummaryCommand; + _getOrganizationReportSummaryDataQuery = getOrganizationReportSummaryDataQuery; + _getOrganizationReportSummaryDataByDateRangeQuery = getOrganizationReportSummaryDataByDateRangeQuery; + _getOrganizationReportDataQuery = getOrganizationReportDataQuery; + _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; + _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; + _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + } + + #region Whole OrganizationReport Endpoints + + [HttpGet("{organizationId}/latest")] + public async Task GetLatestOrganizationReportAsync(Guid organizationId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + + return Ok(latestReport); + } + + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return Ok(report); + } + + [HttpPost("{organizationId}")] + public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + return Ok(report); + } + + [HttpPatch("{organizationId}/{reportId}")] + public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); + return Ok(updatedReport); + } + + #endregion + + # region SummaryData Field Endpoints + + [HttpGet("{organizationId}/data/summary")] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (organizationId.Equals(null)) + { + throw new BadRequestException("Organization ID is required."); + } + + var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery + .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + return Ok(summaryDataList); + } + + [HttpGet("{organizationId}/data/summary/{reportId}")] + public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + if (summaryData == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + return Ok(summaryData); + } + + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + + return Ok(updatedReport); + } + #endregion + + #region ReportData Field Endpoints + + [HttpGet("{organizationId}/data/report/{reportId}")] + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); + + if (reportData == null) + { + throw new NotFoundException("Organization report data not found."); + } + + return Ok(reportData); + } + + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + return Ok(updatedReport); + } + + #endregion + + #region ApplicationData Field Endpoints + + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return Ok(applicationData); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + { + try + { + + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.Id != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + + + + return Ok(updatedReport); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + #endregion +} diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index d643d68661..3e9f2f0e0d 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -25,7 +25,6 @@ public class ReportsController : Controller private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; - private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand; private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; private readonly ILogger _logger; @@ -38,7 +37,6 @@ public class ReportsController : Controller IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, IGetOrganizationReportQuery getOrganizationReportQuery, IAddOrganizationReportCommand addOrganizationReportCommand, - IDropOrganizationReportCommand dropOrganizationReportCommand, ILogger logger ) { @@ -50,7 +48,6 @@ public class ReportsController : Controller _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; _getOrganizationReportQuery = getOrganizationReportQuery; _addOrganizationReportCommand = addOrganizationReportCommand; - _dropOrganizationReportCommand = dropOrganizationReportCommand; _logger = logger; } @@ -209,195 +206,4 @@ public class ReportsController : Controller await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request); } - - /// - /// Adds a new organization report - /// - /// A single instance of AddOrganizationReportRequest - /// A single instance of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpPost("organization-reports")] - public async Task AddOrganizationReport([FromBody] AddOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(request.OrganizationId)) - { - throw new NotFoundException(); - } - return await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - } - - /// - /// Drops organization reports for an organization - /// - /// A single instance of DropOrganizationReportRequest - /// - /// If user does not have access to the organization - /// If the organization does not have any records - [HttpDelete("organization-reports")] - public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(request.OrganizationId)) - { - throw new NotFoundException(); - } - await _dropOrganizationReportCommand.DropOrganizationReportAsync(request); - } - - /// - /// Gets organization reports for an organization - /// - /// A valid Organization Id - /// An Enumerable of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpGet("organization-reports/{orgId}")] - public async Task> GetOrganizationReports(Guid orgId) - { - if (!await _currentContext.AccessReports(orgId)) - { - throw new NotFoundException(); - } - return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId); - } - - /// - /// Gets the latest organization report for an organization - /// - /// A valid Organization Id - /// A single instance of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpGet("organization-reports/latest/{orgId}")] - public async Task GetLatestOrganizationReport(Guid orgId) - { - if (!await _currentContext.AccessReports(orgId)) - { - throw new NotFoundException(); - } - return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId); - } - - /// - /// Gets the Organization Report Summary for an organization. - /// This includes the latest report's encrypted data, encryption key, and date. - /// This is a mock implementation and should be replaced with actual data retrieval logic. - /// - /// - /// Min date (example: 2023-01-01) - /// Max date (example: 2023-12-31) - /// - /// - [HttpGet("organization-report-summary/{orgId}")] - public IEnumerable GetOrganizationReportSummary( - [FromRoute] Guid orgId, - [FromQuery] DateOnly from, - [FromQuery] DateOnly to) - { - if (!ModelState.IsValid) - { - throw new BadRequestException(ModelState); - } - - GuardOrganizationAccess(orgId); - - // FIXME: remove this mock class when actual data retrieval is implemented - return MockOrganizationReportSummary.GetMockData() - .Where(_ => _.OrganizationId == orgId - && _.Date >= from.ToDateTime(TimeOnly.MinValue) - && _.Date <= to.ToDateTime(TimeOnly.MaxValue)); - } - - /// - /// Creates a new Organization Report Summary for an organization. - /// This is a mock implementation and should be replaced with actual creation logic. - /// - /// - /// Returns 204 Created with the created OrganizationReportSummaryModel - /// - [HttpPost("organization-report-summary")] - public IActionResult CreateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model) - { - if (!ModelState.IsValid) - { - throw new BadRequestException(ModelState); - } - - GuardOrganizationAccess(model.OrganizationId); - - // TODO: Implement actual creation logic - - // Returns 204 No Content as a placeholder - return NoContent(); - } - - [HttpPut("organization-report-summary")] - public IActionResult UpdateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model) - { - if (!ModelState.IsValid) - { - throw new BadRequestException(ModelState); - } - - GuardOrganizationAccess(model.OrganizationId); - - // TODO: Implement actual update logic - - // Returns 204 No Content as a placeholder - return NoContent(); - } - - private void GuardOrganizationAccess(Guid organizationId) - { - if (!_currentContext.AccessReports(organizationId).Result) - { - throw new NotFoundException(); - } - } - - // FIXME: remove this mock class when actual data retrieval is implemented - private class MockOrganizationReportSummary - { - public static List GetMockData() - { - return new List - { - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.EtCcxDEBoF1MYChYHC4Q1w==|RyZ07R7qEFBbc/ICLFpEMockL9K+PD6rOod6DGHHrkaRLHUDqDwmxbu3jnD0cg8s7GIYmp0jApHXC+82QdApk87pA0Kr8fN2Rj0+8bDQCjhKfoRTipAB25S/n2E+ttjvlFfag92S66XqUH9S/eZw/Q==|0bPfykHk3SqS/biLNcNoYtH6YTstBEKu3AhvdZZLxhU=", - EncryptionKey = "2.Dd/TtdNwxWdYg9+fRkxh6w==|8KAiK9SoadgFRmyVOchd4tNh2vErD1Rv9x1gqtsE5tzxKE/V/5kkr1WuVG+QpEj//YaQt221UEMESRSXicZ7a9cB6xXLBkbbFwmecQRJVBs=|902em44n9cwciZzYrYuX6MRzRa+4hh1HHfNAxyJx/IM=", - Date = DateTime.UtcNow - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.HvY4fAvbzYV1hqa3255m5Q==|WcKga2Wka5i8fVso8MgjzfBAwxaqdhZDL3bnvhDsisZ0r9lNKQcG3YUQSFpJxr74cgg5QRQaFieCUe2YppciHDT6bsaE2VzFce3cNNB821uTFqnlJClkGJpG1nGvPupdErrg4Ik57WenEzYesmR4pw==|F0aJfF+1MlPm+eAlQnDgFnwfv198N9VtPqFJa4+UFqk=", - EncryptionKey = "2.ctMgLN4ycPusbQArG/uiag==|NtqiQsAoUxMSTBQsxAMyVLWdt5lVEUGZQNxZSBU4l76ywH2f6dx5FWFrcF3t3GBqy5yDoc5eBg0VlJDW9coqzp8j9n8h1iMrtmXPyBMAhbc=|pbH+w68BUdUKYCfNRpjd8NENw2lZ0vfxgMuTrsrRCTQ=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.NH4qLZYUkz/+qpB/mRsLTA==|LEFt05jJz0ngh+Hl5lqk6kebj7lZMefA3eFdL1kLJSGdD3uTOngRwH7GXLQNFeQOxutnLX9YUILbUEPwaM8gCwNQ1KWYdB1Z+Ky4nzKRb60N7L5aTA2za6zXTIdjv7Zwhg0jPZ6sPevTuvSyqjMCuA==|Uuu6gZaF0wvB2mHFwtvHegMxfe8DgsYWTRfGiVn4lkM=", - EncryptionKey = "2.3YwG78ykSxAn44NcymdG4w==|4jfn0nLoFielicAFbmq27DNUUjV4SwGePnjYRmOa7hk4pEPnQRS3MsTJFbutVyXOgKFY9Yn2yGFZownY9EmXOMM+gHPD0t6TfzUKqQcRyuI=|wasP9zZEL9mFH5HzJYrMxnKUr/XlFKXCxG9uW66uaPU=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.YmKWj/707wDPONh+JXPBOw==|Fx4jcUHmnUnSMCU8vdThMSYpDyKPnC09TxpSbNxia0M6MFbd5WHElcVribrYgTENyU0HlqPW43hThJ6xXCM0EjEWP7/jb/0l07vMNkA7sDYq+czf0XnYZgZSGKh06wFVz8xkhaPTdsiO4CXuMsoH+w==|DDVwVFHzdfbPQe3ycCx82eYVHDW97V/eWTPsNpHX/+U=", - EncryptionKey = "2.f/U45I7KF+JKfnvOArUyaw==|zNhhS2q2WwBl6SqLWMkxrXC8EX91Ra9LJExywkJhsRbxubRLt7fK+YWc8T1LUaDmMwJ3G8buSPGzyacKX0lnUR33dW6DIaLNgRZ/ekb/zkg=|qFoIZWwS0foiiIOyikFRwQKmmmI2HeyHcOVklJnIILI=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.WYauwooJUEY3kZsDPphmrA==|oguYW6h10A4GxK4KkRS0X32qSTekU2CkGqNDNGfisUgvJzsyoVTafO9sVcdPdg4BUM7YNkPMjYiKEc5jMHkIgLzbnM27jcGvMJrrccSrLHiWL6/mEiqQkV3TlfiZF9i3wqj1ITsYRzM454uNle6Wrg==|uR67aFYb1i5LSidWib0iTf8091l8GY5olHkVXse3CAw=", - EncryptionKey = "2.ZyV9+9A2cxNaf8dfzfbnlA==|hhorBpVkcrrhTtNmd6SNHYI8gPNokGLOC22Vx8Qa/AotDAcyuYWw56zsawMnzpAdJGEJFtszKM2+VUVOcroCTMWHpy8yNf/kZA6uPk3Lz3s=|ASzVeJf+K1ZB8NXuypamRBGRuRq0GUHZBEy5r/O7ORY=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - }; - } - } } diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 92975ca441..a776648b35 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -9,12 +9,15 @@ public class OrganizationReport : ITableObject { public Guid Id { get; set; } public Guid OrganizationId { get; set; } - public DateTime Date { get; set; } public string ReportData { get; set; } = string.Empty; public DateTime CreationDate { get; set; } = DateTime.UtcNow; public string ContentEncryptionKey { get; set; } = string.Empty; + public string? SummaryData { get; set; } = null; + public string? ApplicationData { get; set; } = null; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs new file mode 100644 index 0000000000..292d8e6f38 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportApplicationDataResponse +{ + public string? ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs new file mode 100644 index 0000000000..c284d99ff2 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportDataResponse +{ + public string? ReportData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs new file mode 100644 index 0000000000..0533c2862f --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportSummaryDataResponse +{ + public string? SummaryData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 66d25cdf56..f0477806d8 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -26,12 +26,12 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand public async Task AddOrganizationReportAsync(AddOrganizationReportRequest request) { - _logger.LogInformation("Adding organization report for organization {organizationId}", request.OrganizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Adding organization report for organization {organizationId}", request.OrganizationId); var (isValid, errorMessage) = await ValidateRequestAsync(request); if (!isValid) { - _logger.LogInformation("Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); + _logger.LogInformation(Constants.BypassFiltersEventId, "Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); throw new BadRequestException(errorMessage); } @@ -39,15 +39,18 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand { OrganizationId = request.OrganizationId, ReportData = request.ReportData, - Date = request.Date == default ? DateTime.UtcNow : request.Date, CreationDate = DateTime.UtcNow, + ContentEncryptionKey = request.ContentEncryptionKey, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData, + RevisionDate = DateTime.UtcNow }; organizationReport.SetNewId(); var data = await _organizationReportRepo.CreateAsync(organizationReport); - _logger.LogInformation("Successfully added organization report for organization {organizationId}, {organizationReportId}", + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully added organization report for organization {organizationId}, {organizationReportId}", request.OrganizationId, data.Id); return data; @@ -63,12 +66,26 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand return (false, "Invalid Organization"); } - // ensure that we have report data + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "Content Encryption Key is required"); + } + if (string.IsNullOrWhiteSpace(request.ReportData)) { return (false, "Report Data is required"); } + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + return (true, string.Empty); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs deleted file mode 100644 index 8fe206c1f1..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class DropOrganizationReportCommand : IDropOrganizationReportCommand -{ - private IOrganizationReportRepository _organizationReportRepo; - private ILogger _logger; - - public DropOrganizationReportCommand( - IOrganizationReportRepository organizationReportRepository, - ILogger logger) - { - _organizationReportRepo = organizationReportRepository; - _logger = logger; - } - - public async Task DropOrganizationReportAsync(DropOrganizationReportRequest request) - { - _logger.LogInformation("Dropping organization report for organization {organizationId}", - request.OrganizationId); - - var data = await _organizationReportRepo.GetByOrganizationIdAsync(request.OrganizationId); - if (data == null || data.Count() == 0) - { - _logger.LogInformation("No organization reports found for organization {organizationId}", request.OrganizationId); - throw new BadRequestException("No data found."); - } - - data - .Where(_ => request.OrganizationReportIds.Contains(_.Id)) - .ToList() - .ForEach(async reportId => - { - _logger.LogInformation("Dropping organization report {organizationReportId} for organization {organizationId}", - reportId, request.OrganizationId); - - await _organizationReportRepo.DeleteAsync(reportId); - }); - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs new file mode 100644 index 0000000000..983fa71fd7 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportApplicationDataQuery : IGetOrganizationReportApplicationDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportApplicationDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + + if (applicationDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No application data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report application data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return applicationDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs new file mode 100644 index 0000000000..d53fa56111 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportDataQuery : IGetOrganizationReportDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var reportDataResponse = await _organizationReportRepo.GetReportDataAsync(reportId); + + if (reportDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No report data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return reportDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs index e536fdfddc..b0bf9e450a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs @@ -19,15 +19,23 @@ public class GetOrganizationReportQuery : IGetOrganizationReportQuery _logger = logger; } - public async Task> GetOrganizationReportAsync(Guid organizationId) + public async Task GetOrganizationReportAsync(Guid reportId) { - if (organizationId == Guid.Empty) + if (reportId == Guid.Empty) { - throw new BadRequestException("OrganizationId is required."); + throw new BadRequestException("Id of report is required."); } - _logger.LogInformation("Fetching organization reports for organization {organizationId}", organizationId); - return await _organizationReportRepo.GetByOrganizationIdAsync(organizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization reports for organization by Id: {reportId}", reportId); + + var results = await _organizationReportRepo.GetByIdAsync(reportId); + + if (results == null) + { + throw new NotFoundException($"No report found for Id: {reportId}"); + } + + return results; } public async Task GetLatestOrganizationReportAsync(Guid organizationId) @@ -37,7 +45,7 @@ public class GetOrganizationReportQuery : IGetOrganizationReportQuery throw new BadRequestException("OrganizationId is required."); } - _logger.LogInformation("Fetching latest organization report for organization {organizationId}", organizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching latest organization report for organization {organizationId}", organizationId); return await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs new file mode 100644 index 0000000000..7be59b822e --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -0,0 +1,89 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportSummaryDataByDateRangeQuery : IGetOrganizationReportSummaryDataByDateRangeQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportSummaryDataByDateRangeQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task> GetOrganizationReportSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + organizationId, startDate, endDate); + + var (isValid, errorMessage) = ValidateRequest(organizationId, startDate, endDate); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataByDateRangeAsync validation failed: {errorMessage}", errorMessage); + throw new BadRequestException(errorMessage); + } + + IEnumerable summaryDataList = (await _organizationReportRepo + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate)) ?? + Enumerable.Empty(); + + var resultList = summaryDataList.ToList(); + + if (!resultList.Any()) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} in date range {startDate} to {endDate}", + organizationId, startDate, endDate); + return Enumerable.Empty(); + } + else + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved {count} organization report summary data records for organization {organizationId} in date range {startDate} to {endDate}", + resultList.Count, organizationId, startDate, endDate); + + } + + return resultList; + } + catch (Exception ex) when (!(ex is BadRequestException)) + { + _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + organizationId, startDate, endDate); + throw; + } + } + + private static (bool IsValid, string errorMessage) ValidateRequest(Guid organizationId, DateTime startDate, DateTime endDate) + { + if (organizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (startDate == default) + { + return (false, "StartDate is required"); + } + + if (endDate == default) + { + return (false, "EndDate is required"); + } + + if (startDate > endDate) + { + return (false, "StartDate must be earlier than or equal to EndDate"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs new file mode 100644 index 0000000000..83ee24a476 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportSummaryDataQuery : IGetOrganizationReportSummaryDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportSummaryDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportSummaryDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var summaryDataResponse = await _organizationReportRepo.GetSummaryDataAsync(reportId); + + if (summaryDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report summary data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return summaryDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs deleted file mode 100644 index 1ed9059f56..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ - -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IDropOrganizationReportCommand -{ - Task DropOrganizationReportAsync(DropOrganizationReportRequest request); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs new file mode 100644 index 0000000000..f7eceea583 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportApplicationDataQuery +{ + Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs new file mode 100644 index 0000000000..3817fa03d2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportDataQuery +{ + Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs index f596e8f517..b72fdd25b5 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs @@ -4,6 +4,6 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IGetOrganizationReportQuery { - Task> GetOrganizationReportAsync(Guid organizationId); + Task GetOrganizationReportAsync(Guid organizationId); Task GetLatestOrganizationReportAsync(Guid organizationId); } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs new file mode 100644 index 0000000000..2659a3d78b --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportSummaryDataByDateRangeQuery +{ + Task> GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, DateTime startDate, DateTime endDate); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs new file mode 100644 index 0000000000..8b208c8a8a --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportSummaryDataQuery +{ + Task GetOrganizationReportSummaryDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs new file mode 100644 index 0000000000..352de679be --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportApplicationDataCommand +{ + Task UpdateOrganizationReportApplicationDataAsync(UpdateOrganizationReportApplicationDataRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs new file mode 100644 index 0000000000..fc947b9f9d --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportCommand +{ + Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs new file mode 100644 index 0000000000..cb212714f2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportDataCommand +{ + Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs new file mode 100644 index 0000000000..bdc2081a1f --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportSummaryCommand +{ + Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index a20c7a3e8f..f89ff97762 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -14,7 +14,14 @@ public static class ReportingServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index f5a3d581f2..2a8c0203f9 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -7,5 +7,10 @@ public class AddOrganizationReportRequest { public Guid OrganizationId { get; set; } public string ReportData { get; set; } - public DateTime Date { get; set; } + + public string ContentEncryptionKey { get; set; } + + public string SummaryData { get; set; } + + public string ApplicationData { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs new file mode 100644 index 0000000000..8949cfdff3 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class GetOrganizationReportSummaryDataByDateRangeRequest +{ + public Guid OrganizationId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs new file mode 100644 index 0000000000..ab4fcc5921 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportApplicationDataRequest +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public string ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs new file mode 100644 index 0000000000..673a3f2ab8 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportDataRequest +{ + public Guid OrganizationId { get; set; } + public Guid ReportId { get; set; } + public string ReportData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs new file mode 100644 index 0000000000..501f5a1a1a --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs @@ -0,0 +1,14 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportRequest +{ + public Guid ReportId { get; set; } + public Guid OrganizationId { get; set; } + public string ReportData { get; set; } + public string ContentEncryptionKey { get; set; } + public string SummaryData { get; set; } = null; + public string ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs new file mode 100644 index 0000000000..b0e555fcef --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportSummaryRequest +{ + public Guid OrganizationId { get; set; } + public Guid ReportId { get; set; } + public string SummaryData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs new file mode 100644 index 0000000000..67ec49d004 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportApplicationDataCommand : IUpdateOrganizationReportApplicationDataCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportApplicationDataCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportApplicationDataAsync(UpdateOrganizationReportApplicationDataRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report application data {reportId} for organization {organizationId}: {errorMessage}", + request.Id, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.Id); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.Id); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.Id, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + throw; + } + } + + private async Task<(bool isValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportApplicationDataRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.Id == Guid.Empty) + { + return (false, "Id is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs new file mode 100644 index 0000000000..7fb77030a8 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs @@ -0,0 +1,124 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportCommand : IUpdateOrganizationReportCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + existingReport.ContentEncryptionKey = request.ContentEncryptionKey; + existingReport.SummaryData = request.SummaryData; + existingReport.ReportData = request.ReportData; + existingReport.ApplicationData = request.ApplicationData; + existingReport.RevisionDate = DateTime.UtcNow; + + await _organizationReportRepo.UpsertAsync(existingReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var response = await _organizationReportRepo.GetByIdAsync(request.ReportId); + + if (response == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found after update", request.ReportId); + throw new NotFoundException("Organization report not found after update"); + } + return response; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "ContentEncryptionKey is required"); + } + + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs new file mode 100644 index 0000000000..f81d24c3d7 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportDataCommand : IUpdateOrganizationReportDataCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportDataCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report data {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportDataRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs new file mode 100644 index 0000000000..6859814d65 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportSummaryCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportSummaryCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report summary {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportSummaryRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs index e7979ca4b7..9687173716 100644 --- a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -1,12 +1,25 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Repositories; namespace Bit.Core.Dirt.Repositories; public interface IOrganizationReportRepository : IRepository { - Task> GetByOrganizationIdAsync(Guid organizationId); - + // Whole OrganizationReport methods Task GetLatestByOrganizationIdAsync(Guid organizationId); + + // SummaryData methods + Task> GetSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate); + Task GetSummaryDataAsync(Guid reportId); + Task UpdateSummaryDataAsync(Guid orgId, Guid reportId, string summaryData); + + // ReportData methods + Task GetReportDataAsync(Guid reportId); + Task UpdateReportDataAsync(Guid orgId, Guid reportId, string reportData); + + // ApplicationData methods + Task GetApplicationDataAsync(Guid reportId); + Task UpdateApplicationDataAsync(Guid orgId, Guid reportId, string applicationData); } diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index 2ce17a9983..3d001cce92 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -3,6 +3,7 @@ using System.Data; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -23,26 +24,153 @@ public class OrganizationReportRepository : Repository { } - public async Task> GetByOrganizationIdAsync(Guid organizationId) + public async Task GetLatestByOrganizationIdAsync(Guid organizationId) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) { - var results = await connection.QueryAsync( - $"[{Schema}].[OrganizationReport_ReadByOrganizationId]", + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetLatestByOrganizationId]", new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); - return results.ToList(); + return result; } } - public async Task GetLatestByOrganizationIdAsync(Guid organizationId) + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) { - return await GetByOrganizationIdAsync(organizationId) - .ContinueWith(task => + using (var connection = new SqlConnection(ConnectionString)) { - var reports = task.Result; - return reports.OrderByDescending(r => r.CreationDate).FirstOrDefault(); - }); + var parameters = new + { + Id = reportId, + OrganizationId = organizationId, + SummaryData = summaryData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateSummaryData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task GetSummaryDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetSummaryDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task> GetSummaryDataByDateRangeAsync( + Guid organizationId, + DateTime startDate, DateTime + endDate) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + StartDate = startDate, + EndDate = endDate + }; + + var results = await connection.QueryAsync( + $"[{Schema}].[OrganizationReport_GetSummariesByDateRange]", + parameters, + commandType: CommandType.StoredProcedure); + + return results; + } + } + + public async Task GetReportDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetReportDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + Id = reportId, + ReportData = reportData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateReportData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task GetApplicationDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetApplicationDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task UpdateApplicationDataAsync(Guid organizationId, Guid reportId, string applicationData) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + Id = reportId, + ApplicationData = applicationData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateApplicationData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } } } diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index c8e5432e03..525c5a479d 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -3,6 +3,7 @@ using AutoMapper; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using LinqToDB; @@ -19,18 +20,6 @@ public class OrganizationReportRepository : IMapper mapper) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationReports) { } - public async Task> GetByOrganizationIdAsync(Guid organizationId) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var results = await dbContext.OrganizationReports - .Where(p => p.OrganizationId == organizationId) - .ToListAsync(); - return Mapper.Map>(results); - } - } - public async Task GetLatestByOrganizationIdAsync(Guid organizationId) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -38,14 +27,161 @@ public class OrganizationReportRepository : var dbContext = GetDatabaseContext(scope); var result = await dbContext.OrganizationReports .Where(p => p.OrganizationId == organizationId) - .OrderByDescending(p => p.Date) + .OrderByDescending(p => p.RevisionDate) .Take(1) .FirstOrDefaultAsync(); - if (result == null) - return default; + if (result == null) return default; return Mapper.Map(result); } } + + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only SummaryData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + SummaryData = summaryData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } + + public async Task GetSummaryDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportSummaryDataResponse + { + SummaryData = p.SummaryData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task> GetSummaryDataByDateRangeAsync( + Guid organizationId, + DateTime startDate, + DateTime endDate) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var results = await dbContext.OrganizationReports + .Where(p => p.OrganizationId == organizationId && + p.CreationDate >= startDate && p.CreationDate <= endDate) + .Select(p => new OrganizationReportSummaryDataResponse + { + SummaryData = p.SummaryData + }) + .ToListAsync(); + + return results; + } + } + + public async Task GetReportDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportDataResponse + { + ReportData = p.ReportData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only ReportData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + ReportData = reportData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } + + public async Task GetApplicationDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportApplicationDataResponse + { + ApplicationData = p.ApplicationData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task UpdateApplicationDataAsync(Guid organizationId, Guid reportId, string applicationData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only ApplicationData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + ApplicationData = applicationData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } } diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql index 087d4b1e09..d6cd206558 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql @@ -1,26 +1,35 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Create] - @Id UNIQUEIDENTIFIER OUTPUT, - @OrganizationId UNIQUEIDENTIFIER, - @Date DATETIME2(7), - @ReportData NVARCHAR(MAX), - @CreationDate DATETIME2(7), - @ContentEncryptionKey VARCHAR(MAX) + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) AS - SET NOCOUNT ON; +BEGIN + SET NOCOUNT ON; - INSERT INTO [dbo].[OrganizationReport]( - [Id], - [OrganizationId], - [Date], - [ReportData], - [CreationDate], - [ContentEncryptionKey] - ) - VALUES ( - @Id, - @OrganizationId, - @Date, - @ReportData, - @CreationDate, - @ContentEncryptionKey + +INSERT INTO [dbo].[OrganizationReport]( + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] +) +VALUES ( + @Id, + @OrganizationId, + @ReportData, + @CreationDate, + @ContentEncryptionKey, + @SummaryData, + @ApplicationData, + @RevisionDate ); +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql new file mode 100644 index 0000000000..83c97b76ee --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetApplicationDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ApplicationData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql new file mode 100644 index 0000000000..1312369fa8 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + ORDER BY [RevisionDate] DESC +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql new file mode 100644 index 0000000000..9905d5aad2 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetReportDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ReportData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql new file mode 100644 index 0000000000..2ab78a2a1e --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [SummaryData] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate + ORDER BY [RevisionDate] DESC +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql new file mode 100644 index 0000000000..ff0023c95b --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetSummaryDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [SummaryData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql deleted file mode 100644 index 6bdcf51f70..0000000000 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationId] - @OrganizationId UNIQUEIDENTIFIER -AS - SET NOCOUNT ON; - - SELECT - * - FROM [dbo].[OrganizationReportView] - WHERE [OrganizationId] = @OrganizationId; diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql new file mode 100644 index 0000000000..4732fb8ef4 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + UPDATE [dbo].[OrganizationReport] + SET + [OrganizationId] = @OrganizationId, + [ReportData] = @ReportData, + [CreationDate] = @CreationDate, + [ContentEncryptionKey] = @ContentEncryptionKey, + [SummaryData] = @SummaryData, + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id; +END; diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql new file mode 100644 index 0000000000..573622a5e0 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateApplicationData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql new file mode 100644 index 0000000000..d7172e100e --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateReportData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ReportData] = @ReportData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql new file mode 100644 index 0000000000..f33f5980e8 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateSummaryData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @SummaryData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [SummaryData] = @SummaryData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql index edc7ff4c92..4c47eafad8 100644 --- a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql +++ b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql @@ -1,19 +1,24 @@ CREATE TABLE [dbo].[OrganizationReport] ( [Id] UNIQUEIDENTIFIER NOT NULL, [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [Date] DATETIME2 (7) NOT NULL, [ReportData] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL, [ContentEncryptionKey] VARCHAR(MAX) NOT NULL, + [SummaryData] NVARCHAR(MAX) NULL, + [ApplicationData] NVARCHAR(MAX) NULL, + [RevisionDate] DATETIME2 (7) NULL, CONSTRAINT [PK_OrganizationReport] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_OrganizationReport_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) -); + ); GO + CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId] - ON [dbo].[OrganizationReport]([OrganizationId] ASC); + ON [dbo].[OrganizationReport] ([OrganizationId] ASC); GO -CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_Date] - ON [dbo].[OrganizationReport]([OrganizationId] ASC, [Date] DESC); + +CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_RevisionDate] + ON [dbo].[OrganizationReport]([OrganizationId] ASC, [RevisionDate] DESC); GO + diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs new file mode 100644 index 0000000000..c786fd1c1b --- /dev/null +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -0,0 +1,1165 @@ +using Bit.Api.Dirt.Controllers; +using Bit.Core.Context; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Dirt; + +[ControllerCustomize(typeof(OrganizationReportsController))] +[SutProviderCustomize] +public class OrganizationReportControllerTests +{ + #region Whole OrganizationReport Endpoints + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(Task.FromResult(false)); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWithNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns((OrganizationReport)null); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Null(okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetLatestOrganizationReportAsync(orgId); + } + + + + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(Task.FromResult(false)); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); + + Assert.Equal("Report not found for the specified organization.", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportAsync(reportId); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportAsync(reportId); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .AddOrganizationReportAsync(request); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportAsync(request); + } + + #endregion + + #region SummaryData Field Endpoints + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParameters_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate, + List expectedSummaryData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) + .Returns(expectedSummaryData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedSummaryData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate, + List expectedSummaryData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) + .Returns(expectedSummaryData); + + // Act + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportSummaryDataResponse expectedSummaryData) + { + // Arrange + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataAsync(orgId, reportId) + .Returns(expectedSummaryData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedSummaryData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportSummaryDataAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportSummaryAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportSummaryAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportSummaryAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedReportId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = Guid.NewGuid(); // Different from reportId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + + Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportSummaryAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportSummaryAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportSummaryAsync(request); + } + + #endregion + + #region ReportData Field Endpoints + + [Theory, BitAutoData] + public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportDataResponse expectedReportData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportDataAsync(orgId, reportId) + .Returns(expectedReportData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReportData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportDataAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportDataResponse expectedReportData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportDataAsync(orgId, reportId) + .Returns(expectedReportData); + + // Act + await sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportDataAsync(orgId, reportId); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportDataAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = Guid.NewGuid(); // Different from reportId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + + Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportDataAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportDataAsync(request); + } + + #endregion + + #region ApplicationData Field Endpoints + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportApplicationDataResponse expectedApplicationData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportApplicationDataAsync(orgId, reportId) + .Returns(expectedApplicationData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedApplicationData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportApplicationDataAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenApplicationDataNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportApplicationDataAsync(orgId, reportId) + .Returns((OrganizationReportApplicationDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId)); + + Assert.Equal("Organization report application data not found.", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportApplicationDataResponse expectedApplicationData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportApplicationDataAsync(orgId, reportId) + .Returns(expectedApplicationData); + + // Act + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportApplicationDataAsync(orgId, reportId); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.Id = reportId; + expectedReport.Id = request.Id; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedReportId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request, + OrganizationReport updatedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(request) + .Returns(updatedReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + + Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.Id = reportId; + expectedReport.Id = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportApplicationDataAsync(request); + } + + #endregion +} diff --git a/test/Api.Test/Dirt/ReportsControllerTests.cs b/test/Api.Test/Dirt/ReportsControllerTests.cs index 4636406df5..37a6cb79c3 100644 --- a/test/Api.Test/Dirt/ReportsControllerTests.cs +++ b/test/Api.Test/Dirt/ReportsControllerTests.cs @@ -1,14 +1,12 @@ using AutoFixture; using Bit.Api.Dirt.Controllers; using Bit.Api.Dirt.Models; -using Bit.Api.Dirt.Models.Response; using Bit.Core.Context; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; @@ -144,323 +142,4 @@ public class ReportsControllerTests _.OrganizationId == request.OrganizationId && _.PasswordHealthReportApplicationIds == request.PasswordHealthReportApplicationIds)); } - - [Theory, BitAutoData] - public async Task AddOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - - // Act - var request = new AddOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - ReportData = "Report Data", - Date = DateTime.UtcNow - }; - await sutProvider.Sut.AddOrganizationReport(request); - - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .AddOrganizationReportAsync(Arg.Is(_ => - _.OrganizationId == request.OrganizationId && - _.ReportData == request.ReportData && - _.Date == request.Date)); - } - - [Theory, BitAutoData] - public async Task AddOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - // Act - var request = new AddOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - ReportData = "Report Data", - Date = DateTime.UtcNow - }; - await Assert.ThrowsAsync(async () => - await sutProvider.Sut.AddOrganizationReport(request)); - // Assert - _ = sutProvider.GetDependency() - .Received(0); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - // Act - var request = new DropOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - OrganizationReportIds = new List { Guid.NewGuid(), Guid.NewGuid() } - }; - await sutProvider.Sut.DropOrganizationReport(request); - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .DropOrganizationReportAsync(Arg.Is(_ => - _.OrganizationId == request.OrganizationId && - _.OrganizationReportIds.SequenceEqual(request.OrganizationReportIds))); - } - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - // Act - var request = new DropOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - OrganizationReportIds = new List { Guid.NewGuid(), Guid.NewGuid() } - }; - await Assert.ThrowsAsync(async () => - await sutProvider.Sut.DropOrganizationReport(request)); - // Assert - _ = sutProvider.GetDependency() - .Received(0); - } - [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - // Act - var orgId = Guid.NewGuid(); - var result = await sutProvider.Sut.GetOrganizationReports(orgId); - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(Arg.Is(_ => _ == orgId)); - } - - [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - // Act - var orgId = Guid.NewGuid(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReports(orgId)); - // Assert - _ = sutProvider.GetDependency() - .Received(0); - - } - - [Theory, BitAutoData] - public async Task GetLastestOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - - // Act - var orgId = Guid.NewGuid(); - var result = await sutProvider.Sut.GetLatestOrganizationReport(orgId); - - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .GetLatestOrganizationReportAsync(Arg.Is(_ => _ == orgId)); - } - - [Theory, BitAutoData] - public async Task GetLastestOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - - // Act - var orgId = Guid.NewGuid(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetLatestOrganizationReport(orgId)); - - // Assert - _ = sutProvider.GetDependency() - .Received(0); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ReturnsNoContent_WhenAccessGranted(SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key", - Date = DateTime.UtcNow - }; - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.CreateOrganizationReportSummary(model); - - // Assert - Assert.IsType(result); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ThrowsNotFoundException_WhenAccessDenied(SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key", - Date = DateTime.UtcNow - }; - sutProvider.GetDependency().AccessReports(orgId).Returns(false); - - // Act & Assert - Assert.Throws( - () => sutProvider.Sut.CreateOrganizationReportSummary(model)); - } - - [Theory, BitAutoData] - public void GetOrganizationReportSummary_ThrowsNotFoundException_WhenAccessDenied( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - - sutProvider.GetDependency().AccessReports(orgId).Returns(false); - - // Act & Assert - Assert.Throws( - () => sutProvider.Sut.GetOrganizationReportSummary(orgId, DateOnly.FromDateTime(DateTime.UtcNow), DateOnly.FromDateTime(DateTime.UtcNow))); - } - - [Theory, BitAutoData] - public void GetOrganizationReportSummary_returnsExpectedResult( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var dates = new[] - { - DateOnly.FromDateTime(DateTime.UtcNow), - DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-1)) - }; - - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.GetOrganizationReportSummary(orgId, dates[0], dates[1]); - - // Assert - Assert.NotNull(result); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ReturnsNoContent_WhenModelIsValidAndAccessGranted( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.Clear(); - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.CreateOrganizationReportSummary(model); - - // Assert - Assert.IsType(result); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ThrowsBadRequestException_WhenModelStateIsInvalid( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.AddModelError("key", "error"); - - // Act & Assert - Assert.Throws(() => sutProvider.Sut.CreateOrganizationReportSummary(model)); - } - - [Theory, BitAutoData] - public void UpdateOrganizationReportSummary_ReturnsNoContent_WhenModelIsValidAndAccessGranted( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.Clear(); - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.UpdateOrganizationReportSummary(model); - - // Assert - Assert.IsType(result); - } - - [Theory, BitAutoData] - public void UpdateOrganizationReportSummary_ThrowsBadRequestException_WhenModelStateIsInvalid( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.AddModelError("key", "error"); - - // Act & Assert - Assert.Throws(() => sutProvider.Sut.UpdateOrganizationReportSummary(model)); - } - - [Theory, BitAutoData] - public void UpdateOrganizationReportSummary_ThrowsNotFoundException_WhenAccessDenied( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.Clear(); - sutProvider.GetDependency().AccessReports(orgId).Returns(false); - - // Act & Assert - Assert.Throws(() => sutProvider.Sut.UpdateOrganizationReportSummary(model)); - } } diff --git a/test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs deleted file mode 100644 index f6a5c13be9..0000000000 --- a/test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class DeleteOrganizationReportCommandTests -{ - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withValidRequest_Success( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var OrganizationReports = fixture.CreateMany(2).ToList(); - // only take one id from the list - we only want to drop one record - var request = fixture.Build() - .With(x => x.OrganizationReportIds, - OrganizationReports.Select(x => x.Id).Take(1).ToList()) - .Create(); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(OrganizationReports); - - // Act - await sutProvider.Sut.DropOrganizationReportAsync(request); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .GetByOrganizationIdAsync(request.OrganizationId); - - await sutProvider.GetDependency() - .Received(1) - .DeleteAsync(Arg.Is(_ => - request.OrganizationReportIds.Contains(_.Id))); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withValidRequest_nothingToDrop( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var OrganizationReports = fixture.CreateMany(2).ToList(); - // we are passing invalid data - var request = fixture.Build() - .With(x => x.OrganizationReportIds, new List { Guid.NewGuid() }) - .Create(); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(OrganizationReports); - - // Act - await sutProvider.Sut.DropOrganizationReportAsync(request); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .GetByOrganizationIdAsync(request.OrganizationId); - - await sutProvider.GetDependency() - .Received(0) - .DeleteAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withNodata_fails( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - // we are passing invalid data - var request = fixture.Build() - .Create(); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(null as List); - - // Act - await Assert.ThrowsAsync(() => - sutProvider.Sut.DropOrganizationReportAsync(request)); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .GetByOrganizationIdAsync(request.OrganizationId); - - await sutProvider.GetDependency() - .Received(0) - .DeleteAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withInvalidOrganizationId_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(null as List); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withInvalidOrganizationReportId_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(new List()); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withNullOrganizationId_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationId, default(Guid)) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withNullOrganizationReportIds_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationReportIds, default(List)) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withEmptyOrganizationReportIds_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationReportIds, new List()) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withEmptyRequest_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var request = new DropOrganizationReportRequest(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - -} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs new file mode 100644 index 0000000000..c9281d52d1 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs @@ -0,0 +1,116 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportApplicationDataQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithValidParams_ShouldReturnApplicationData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var applicationDataResponse = fixture.Build() + .Create(); + + sutProvider.GetDependency() + .GetApplicationDataAsync(reportId) + .Returns(applicationDataResponse); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).GetApplicationDataAsync(reportId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(Guid.Empty, reportId)); + + Assert.Equal("OrganizationId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetApplicationDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, Guid.Empty)); + + Assert.Equal("ReportId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetApplicationDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetApplicationDataAsync(reportId) + .Returns((OrganizationReportApplicationDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId)); + + Assert.Equal("Organization report application data not found.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetApplicationDataAsync(reportId) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs new file mode 100644 index 0000000000..3c00c6870a --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs @@ -0,0 +1,116 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportDataQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WithValidParams_ShouldReturnReportData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var reportDataResponse = fixture.Build() + .Create(); + + sutProvider.GetDependency() + .GetReportDataAsync(reportId) + .Returns(reportDataResponse); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).GetReportDataAsync(reportId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(Guid.Empty, reportId)); + + Assert.Equal("OrganizationId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetReportDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, Guid.Empty)); + + Assert.Equal("ReportId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetReportDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetReportDataAsync(reportId) + .Returns((OrganizationReportDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId)); + + Assert.Equal("Organization report data not found.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetReportDataAsync(reportId) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs deleted file mode 100644 index 19d020be12..0000000000 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class GetOrganizationReportQueryTests -{ - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithValidOrganizationId_ShouldReturnOrganizationReport( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(fixture.CreateMany(2).ToList()); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportAsync(organizationId); - - // Assert - Assert.NotNull(result); - Assert.True(result.Count() == 2); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithInvalidOrganizationId_ShouldFail( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Is(x => x == Guid.Empty)) - .Returns(new List()); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(Guid.Empty)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithValidOrganizationId_ShouldReturnOrganizationReport( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetLatestByOrganizationIdAsync(Arg.Any()) - .Returns(fixture.Create()); - - // Act - var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId); - - // Assert - Assert.NotNull(result); - } - - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithInvalidOrganizationId_ShouldFail( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - sutProvider.GetDependency() - .GetLatestByOrganizationIdAsync(Arg.Is(x => x == Guid.Empty)) - .Returns(default(OrganizationReport)); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(Guid.Empty)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithNoReports_ShouldReturnEmptyList( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(new List()); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportAsync(organizationId); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - } - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithNoReports_ShouldReturnNull( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetLatestByOrganizationIdAsync(Arg.Any()) - .Returns(default(OrganizationReport)); - - // Act - var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId); - - // Assert - Assert.Null(result); - } - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithNullOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = default(Guid); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithNullOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = default(Guid); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithInvalidOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = Guid.Empty; - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithInvalidOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = Guid.Empty; - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs new file mode 100644 index 0000000000..572b7e21fb --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs @@ -0,0 +1,133 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportSummaryDataByDateRangeQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParams_ShouldReturnSummaryData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + var summaryDataList = fixture.Build() + .CreateMany(3); + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .Returns(summaryDataList); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count()); + await sutProvider.GetDependency() + .Received(1).GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(Guid.Empty, startDate, endDate)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive() + .GetSummaryDataByDateRangeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithStartDateAfterEndDate_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow; + var endDate = DateTime.UtcNow.AddDays(-30); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); + + Assert.Equal("StartDate must be earlier than or equal to EndDate", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyResult_ShouldReturnEmptyList( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs new file mode 100644 index 0000000000..c6ede1fcab --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs @@ -0,0 +1,116 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportSummaryDataQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WithValidParams_ShouldReturnSummaryData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var summaryDataResponse = fixture.Build() + .Create(); + + sutProvider.GetDependency() + .GetSummaryDataAsync(reportId) + .Returns(summaryDataResponse); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).GetSummaryDataAsync(reportId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(Guid.Empty, reportId)); + + Assert.Equal("OrganizationId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, Guid.Empty)); + + Assert.Equal("ReportId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetSummaryDataAsync(reportId) + .Returns((OrganizationReportSummaryDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId)); + + Assert.Equal("Organization report summary data not found.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetSummaryDataAsync(reportId) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs new file mode 100644 index 0000000000..bd6eee79d9 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs @@ -0,0 +1,252 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportApplicationDataCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.Id, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.ApplicationData, "updated application data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData) + .Returns(updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + await sutProvider.GetDependency() + .Received(1).UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateApplicationDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithEmptyId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.Id, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Id is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateApplicationDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithEmptyApplicationData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ApplicationData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Application Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithNullApplicationData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ApplicationData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Application Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData) + .Throws(new InvalidOperationException("Database connection failed")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Database connection failed", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs new file mode 100644 index 0000000000..3a84eb0d80 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs @@ -0,0 +1,230 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.ReportData, "updated report data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .With(x => x.ReportData, request.ReportData) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpsertAsync(Arg.Any()) + .Returns(Task.CompletedTask); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport, updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + Assert.Equal(updatedReport.ReportData, result.ReportData); + + await sutProvider.GetDependency() + .Received(1).GetByIdAsync(request.OrganizationId); + await sutProvider.GetDependency() + .Received(2).GetByIdAsync(request.ReportId); + await sutProvider.GetDependency() + .Received(1).UpsertAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("ReportId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithEmptyReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithNullReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs new file mode 100644 index 0000000000..02cd74cbf6 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs @@ -0,0 +1,252 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportDataCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.ReportData, "updated report data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData) + .Returns(updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + await sutProvider.GetDependency() + .Received(1).UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateReportDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("ReportId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateReportDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithEmptyReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithNullReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData) + .Throws(new InvalidOperationException("Database connection failed")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Database connection failed", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs new file mode 100644 index 0000000000..dae3ff35ba --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs @@ -0,0 +1,252 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportSummaryCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.SummaryData, "updated summary data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData) + .Returns(updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + await sutProvider.GetDependency() + .Received(1).UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateSummaryDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("ReportId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateSummaryDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithEmptySummaryData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.SummaryData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Summary Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithNullSummaryData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.SummaryData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Summary Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData) + .Throws(new InvalidOperationException("Database connection failed")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Database connection failed", exception.Message); + } +} diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index dd2adc0970..abf16a56e6 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -42,8 +42,8 @@ public class OrganizationReportRepositoryTests var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization); report.OrganizationId = sqlOrganization.Id; - var sqlOrgnizationReportRecord = await sqlOrganizationReportRepo.CreateAsync(report); - var savedSqlOrganizationReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlOrgnizationReportRecord.Id); + var sqlOrganizationReportRecord = await sqlOrganizationReportRepo.CreateAsync(report); + var savedSqlOrganizationReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlOrganizationReportRecord.Id); records.Add(savedSqlOrganizationReport); Assert.True(records.Count == 4); @@ -51,17 +51,19 @@ public class OrganizationReportRepositoryTests [CiSkippedTheory, EfOrganizationReportAutoData] public async Task RetrieveByOrganisation_Works( - OrganizationReportRepository sqlPasswordHealthReportApplicationRepo, + OrganizationReportRepository sqlOrganizationReportRepo, SqlRepo.OrganizationRepository sqlOrganizationRepo) { - var (firstOrg, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo); - var (secondOrg, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo); + var (firstOrg, firstReport) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var (secondOrg, secondReport) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); - var firstSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(firstOrg.Id); - var nextSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(secondOrg.Id); + var firstRetrievedReport = await sqlOrganizationReportRepo.GetByIdAsync(firstReport.Id); + var secondRetrievedReport = await sqlOrganizationReportRepo.GetByIdAsync(secondReport.Id); - Assert.True(firstSetOfRecords.Count == 1 && firstSetOfRecords.First().OrganizationId == firstOrg.Id); - Assert.True(nextSetOfRecords.Count == 1 && nextSetOfRecords.First().OrganizationId == secondOrg.Id); + Assert.NotNull(firstRetrievedReport); + Assert.NotNull(secondRetrievedReport); + Assert.Equal(firstOrg.Id, firstRetrievedReport.OrganizationId); + Assert.Equal(secondOrg.Id, secondRetrievedReport.OrganizationId); } [CiSkippedTheory, EfOrganizationReportAutoData] @@ -112,6 +114,251 @@ public class OrganizationReportRepositoryTests Assert.True(dbRecords.Where(_ => _ == null).Count() == 4); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetLatestByOrganizationIdAsync_ShouldReturnLatestReport( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, firstReport) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + + // Create a second report for the same organization with a later revision date + var fixture = new Fixture(); + var secondReport = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.RevisionDate, firstReport.RevisionDate.AddMinutes(30)) + .Create(); + + await sqlOrganizationReportRepo.CreateAsync(secondReport); + + // Act + var latestReport = await sqlOrganizationReportRepo.GetLatestByOrganizationIdAsync(org.Id); + + // Assert + Assert.NotNull(latestReport); + Assert.Equal(org.Id, latestReport.OrganizationId); + Assert.True(latestReport.RevisionDate >= firstReport.RevisionDate); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateSummaryDataAsync_ShouldUpdateSummaryAndRevisionDate( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (_, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + report.RevisionDate = DateTime.UtcNow.Subtract(TimeSpan.FromDays(1)); // ensure old revision date + var newSummaryData = "Updated summary data"; + var originalRevisionDate = report.RevisionDate; + + // Act + var updatedReport = await sqlOrganizationReportRepo.UpdateSummaryDataAsync(report.OrganizationId, report.Id, newSummaryData); + + // Assert + Assert.NotNull(updatedReport); + Assert.Equal(newSummaryData, updatedReport.SummaryData); + Assert.True(updatedReport.RevisionDate > originalRevisionDate); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataAsync_ShouldReturnSummaryData( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var summaryData = "Test summary data"; + var (org, report) = await CreateOrganizationAndReportWithSummaryDataAsync( + sqlOrganizationRepo, sqlOrganizationReportRepo, summaryData); + + // Act + var result = await sqlOrganizationReportRepo.GetSummaryDataAsync(report.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(summaryData, result.SummaryData); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataByDateRangeAsync_ShouldReturnFilteredResults( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var baseDate = DateTime.UtcNow; + var startDate = baseDate.AddDays(-10); + var endDate = baseDate.AddDays(1); + + // Create organization first + var fixture = new Fixture(); + var organization = fixture.Create(); + var org = await sqlOrganizationRepo.CreateAsync(organization); + + // Create first report with a date within range + var report1 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 1") + .With(x => x.CreationDate, baseDate.AddDays(-5)) // Within range + .With(x => x.RevisionDate, baseDate.AddDays(-5)) + .Create(); + await sqlOrganizationReportRepo.CreateAsync(report1); + + // Create second report with a date within range + var report2 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 2") + .With(x => x.CreationDate, baseDate.AddDays(-3)) // Within range + .With(x => x.RevisionDate, baseDate.AddDays(-3)) + .Create(); + await sqlOrganizationReportRepo.CreateAsync(report2); + + // Act + var results = await sqlOrganizationReportRepo.GetSummaryDataByDateRangeAsync( + org.Id, startDate, endDate); + + // Assert + Assert.NotNull(results); + var resultsList = results.ToList(); + Assert.True(resultsList.Count >= 2, $"Expected at least 2 results, but got {resultsList.Count}"); + Assert.All(resultsList, r => Assert.NotNull(r.SummaryData)); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetReportDataAsync_ShouldReturnReportData( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var fixture = new Fixture(); + var reportData = "Test report data"; + var (org, report) = await CreateOrganizationAndReportWithReportDataAsync( + sqlOrganizationRepo, sqlOrganizationReportRepo, reportData); + + // Act + var result = await sqlOrganizationReportRepo.GetReportDataAsync(report.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(reportData, result.ReportData); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateReportDataAsync_ShouldUpdateReportDataAndRevisionDate( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var newReportData = "Updated report data"; + var originalRevisionDate = report.RevisionDate; + + // Add a small delay to ensure revision date difference + await Task.Delay(100); + + // Act + var updatedReport = await sqlOrganizationReportRepo.UpdateReportDataAsync( + org.Id, report.Id, newReportData); + + // Assert + Assert.NotNull(updatedReport); + Assert.Equal(org.Id, updatedReport.OrganizationId); + Assert.Equal(report.Id, updatedReport.Id); + Assert.Equal(newReportData, updatedReport.ReportData); + Assert.True(updatedReport.RevisionDate >= originalRevisionDate, + $"Expected RevisionDate {updatedReport.RevisionDate} to be >= {originalRevisionDate}"); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetApplicationDataAsync_ShouldReturnApplicationData( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var applicationData = "Test application data"; + var (org, report) = await CreateOrganizationAndReportWithApplicationDataAsync( + sqlOrganizationRepo, sqlOrganizationReportRepo, applicationData); + + // Act + var result = await sqlOrganizationReportRepo.GetApplicationDataAsync(report.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(applicationData, result.ApplicationData); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateApplicationDataAsync_ShouldUpdateApplicationDataAndRevisionDate( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var newApplicationData = "Updated application data"; + var originalRevisionDate = DateTime.UtcNow.Subtract(TimeSpan.FromDays(1)); // ensure old revision date + + // Add a small delay to ensure revision date difference + await Task.Delay(100); + + // Act + var updatedReport = await sqlOrganizationReportRepo.UpdateApplicationDataAsync( + org.Id, report.Id, newApplicationData); + + // Assert + Assert.NotNull(updatedReport); + Assert.Equal(org.Id, updatedReport.OrganizationId); + Assert.Equal(report.Id, updatedReport.Id); + Assert.Equal(newApplicationData, updatedReport.ApplicationData); + Assert.True(updatedReport.RevisionDate >= originalRevisionDate, + $"Expected RevisionDate {updatedReport.RevisionDate} to be >= {originalRevisionDate}"); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataAsync_WithNonExistentReport_ShouldReturnNull( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var nonExistentReportId = Guid.NewGuid(); + + // Act + var result = await sqlOrganizationReportRepo.GetSummaryDataAsync(nonExistentReportId); + + // Assert + Assert.Null(result); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetReportDataAsync_WithNonExistentReport_ShouldReturnNull( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var nonExistentReportId = Guid.NewGuid(); + + // Act + var result = await sqlOrganizationReportRepo.GetReportDataAsync(nonExistentReportId); + + // Assert + Assert.Null(result); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetApplicationDataAsync_WithNonExistentReport_ShouldReturnNull( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var nonExistentReportId = Guid.NewGuid(); + + // Act + var result = await sqlOrganizationReportRepo.GetApplicationDataAsync(nonExistentReportId); + + // Assert + Assert.Null(result); + } + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportAsync( IOrganizationRepository orgRepo, IOrganizationReportRepository orgReportRepo) @@ -121,6 +368,64 @@ public class OrganizationReportRepositoryTests var orgReportRecord = fixture.Build() .With(x => x.OrganizationId, organization.Id) + .With(x => x.RevisionDate, organization.RevisionDate) + .Create(); + + organization = await orgRepo.CreateAsync(organization); + orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); + + return (organization, orgReportRecord); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithSummaryDataAsync( + IOrganizationRepository orgRepo, + IOrganizationReportRepository orgReportRepo, + string summaryData) + { + var fixture = new Fixture(); + var organization = fixture.Create(); + + var orgReportRecord = fixture.Build() + .With(x => x.OrganizationId, organization.Id) + .With(x => x.SummaryData, summaryData) + .Create(); + + organization = await orgRepo.CreateAsync(organization); + orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); + + return (organization, orgReportRecord); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithReportDataAsync( + IOrganizationRepository orgRepo, + IOrganizationReportRepository orgReportRepo, + string reportData) + { + var fixture = new Fixture(); + var organization = fixture.Create(); + + var orgReportRecord = fixture.Build() + .With(x => x.OrganizationId, organization.Id) + .With(x => x.ReportData, reportData) + .Create(); + + organization = await orgRepo.CreateAsync(organization); + orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); + + return (organization, orgReportRecord); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithApplicationDataAsync( + IOrganizationRepository orgRepo, + IOrganizationReportRepository orgReportRepo, + string applicationData) + { + var fixture = new Fixture(); + var organization = fixture.Create(); + + var orgReportRecord = fixture.Build() + .With(x => x.OrganizationId, organization.Id) + .With(x => x.ApplicationData, applicationData) .Create(); organization = await orgRepo.CreateAsync(organization); diff --git a/util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql b/util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql new file mode 100644 index 0000000000..912fe0de46 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql @@ -0,0 +1,96 @@ +IF EXISTS ( +SELECT * FROM sys.indexes WHERE name = 'IX_OrganizationReport_OrganizationId_Date' +AND object_id = OBJECT_ID('dbo.OrganizationReport') +) +BEGIN + DROP INDEX [IX_OrganizationReport_OrganizationId_Date] ON [dbo].[OrganizationReport]; +END +GO + +IF COL_LENGTH('[dbo].[OrganizationReport]', 'Date') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[OrganizationReport] + DROP COLUMN [Date]; +END +GO + +IF OBJECT_ID('dbo.OrganizationReport') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[OrganizationReport] + ADD [SummaryData] NVARCHAR(MAX) NULL, + [ApplicationData] NVARCHAR(MAX) NULL, + [RevisionDate] DATETIME2 (7) NULL; +END +GO + +IF NOT EXISTS ( +SELECT * FROM sys.indexes WHERE name = 'IX_OrganizationReport_OrganizationId_RevisionDate' +AND object_id = OBJECT_ID('dbo.OrganizationReport') +) +BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_RevisionDate] + ON [dbo].[OrganizationReport]([OrganizationId] ASC, [RevisionDate] DESC); +END +GO + +IF OBJECT_ID('dbo.OrganizationReportView') IS NOT NULL +BEGIN + DROP VIEW [dbo].[OrganizationReportView]; +END +GO + +IF OBJECT_ID('dbo.OrganizationReportView') IS NULL +BEGIN + EXEC('CREATE VIEW [dbo].[OrganizationReportView] + AS + SELECT + * + FROM + [dbo].[OrganizationReport]'); +END +GO + +IF OBJECT_ID('dbo.OrganizationReport_Create') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationReport_Create]; +END +GO + +IF OBJECT_ID('dbo.OrganizationReport_Create') IS NULL +BEGIN + EXEC('CREATE PROCEDURE [dbo].[OrganizationReport_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) + AS + BEGIN + SET NOCOUNT ON; + + INSERT INTO [dbo].[OrganizationReport]( + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + ) + VALUES ( + @Id, + @OrganizationId, + @ReportData, + @CreationDate, + @ContentEncryptionKey, + @SummaryData, + @ApplicationData, + @RevisionDate + ); + END'); +END +GO diff --git a/util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql b/util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql new file mode 100644 index 0000000000..6f64a3ee6a --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql @@ -0,0 +1,156 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + ORDER BY [RevisionDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + +SELECT + [SummaryData] +FROM [dbo].[OrganizationReportView] +WHERE [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate +ORDER BY [RevisionDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetSummaryDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + +SELECT + [SummaryData] +FROM [dbo].[OrganizationReportView] +WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateSummaryData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @SummaryData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + +UPDATE [dbo].[OrganizationReport] +SET + [SummaryData] = @SummaryData, + [RevisionDate] = @RevisionDate +WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetReportDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ReportData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateReportData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + +UPDATE [dbo].[OrganizationReport] +SET + [ReportData] = @ReportData, + [RevisionDate] = @RevisionDate +WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetApplicationDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ApplicationData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateApplicationData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + UPDATE [dbo].[OrganizationReport] + SET + [OrganizationId] = @OrganizationId, + [ReportData] = @ReportData, + [CreationDate] = @CreationDate, + [ContentEncryptionKey] = @ContentEncryptionKey, + [SummaryData] = @SummaryData, + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id; +END +GO diff --git a/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs new file mode 100644 index 0000000000..2a75cf0ed9 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs @@ -0,0 +1,3275 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250825064449_2025-08-22_00_AlterOrganizationReport")] + partial class _20250822_00_AlterOrganizationReport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasColumnType("longtext"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs new file mode 100644 index 0000000000..3dcb753f34 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class _20250822_00_AlterOrganizationReport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Date", + table: "OrganizationReport", + newName: "RevisionDate"); + + migrationBuilder.AddColumn( + name: "ApplicationData", + table: "OrganizationReport", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "SummaryData", + table: "OrganizationReport", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationData", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "SummaryData", + table: "OrganizationReport"); + + migrationBuilder.RenameColumn( + name: "RevisionDate", + table: "OrganizationReport", + newName: "Date"); + } +} diff --git a/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs new file mode 100644 index 0000000000..c94c03b193 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs @@ -0,0 +1,3275 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures")] + partial class _20250822_01_AddOrganizationReportStoredProcedures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasColumnType("longtext"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs new file mode 100644 index 0000000000..e9aaf605f3 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class _20250822_01_AddOrganizationReportStoredProcedures : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 2500cc3623..69301d7e54 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1011,6 +1011,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Id") .HasColumnType("char(36)"); + b.Property("ApplicationData") + .HasColumnType("longtext"); + b.Property("ContentEncryptionKey") .IsRequired() .HasColumnType("longtext"); @@ -1018,9 +1021,6 @@ namespace Bit.MySqlMigrations.Migrations b.Property("CreationDate") .HasColumnType("datetime(6)"); - b.Property("Date") - .HasColumnType("datetime(6)"); - b.Property("OrganizationId") .HasColumnType("char(36)"); @@ -1028,6 +1028,12 @@ namespace Bit.MySqlMigrations.Migrations .IsRequired() .HasColumnType("longtext"); + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + b.HasKey("Id"); b.HasIndex("Id") diff --git a/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs new file mode 100644 index 0000000000..cc45046c33 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs @@ -0,0 +1,3281 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250825064440_2025-08-22_00_AlterOrganizationReport")] + partial class _20250822_00_AlterOrganizationReport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs new file mode 100644 index 0000000000..f5449e8a57 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class _20250822_00_AlterOrganizationReport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Date", + table: "OrganizationReport", + newName: "RevisionDate"); + + migrationBuilder.AddColumn( + name: "ApplicationData", + table: "OrganizationReport", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "SummaryData", + table: "OrganizationReport", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationData", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "SummaryData", + table: "OrganizationReport"); + + migrationBuilder.RenameColumn( + name: "RevisionDate", + table: "OrganizationReport", + newName: "Date"); + } +} diff --git a/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs new file mode 100644 index 0000000000..d04e454476 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs @@ -0,0 +1,3281 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures")] + partial class _20250822_01_AddOrganizationReportStoredProcedures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs new file mode 100644 index 0000000000..605d2ab01a --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class _20250822_01_AddOrganizationReportStoredProcedures : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 41f49e6e63..b0e34084e8 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1016,6 +1016,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("ApplicationData") + .HasColumnType("text"); + b.Property("ContentEncryptionKey") .IsRequired() .HasColumnType("text"); @@ -1023,9 +1026,6 @@ namespace Bit.PostgresMigrations.Migrations b.Property("CreationDate") .HasColumnType("timestamp with time zone"); - b.Property("Date") - .HasColumnType("timestamp with time zone"); - b.Property("OrganizationId") .HasColumnType("uuid"); @@ -1033,6 +1033,12 @@ namespace Bit.PostgresMigrations.Migrations .IsRequired() .HasColumnType("text"); + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("Id") diff --git a/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs new file mode 100644 index 0000000000..6aee5c15f0 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs @@ -0,0 +1,3264 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250825064445_2025-08-22_00_AlterOrganizationReport")] + partial class _20250822_00_AlterOrganizationReport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs new file mode 100644 index 0000000000..32ecf61589 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class _20250822_00_AlterOrganizationReport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Date", + table: "OrganizationReport", + newName: "RevisionDate"); + + migrationBuilder.AddColumn( + name: "ApplicationData", + table: "OrganizationReport", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SummaryData", + table: "OrganizationReport", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationData", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "SummaryData", + table: "OrganizationReport"); + + migrationBuilder.RenameColumn( + name: "RevisionDate", + table: "OrganizationReport", + newName: "Date"); + } +} diff --git a/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs new file mode 100644 index 0000000000..e33f825366 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs @@ -0,0 +1,3264 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures")] + partial class _20250822_01_AddOrganizationReportStoredProcedures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs new file mode 100644 index 0000000000..a26a75078f --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class _20250822_01_AddOrganizationReportStoredProcedures : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 11d1517a05..caee8fef2a 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1000,6 +1000,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Id") .HasColumnType("TEXT"); + b.Property("ApplicationData") + .HasColumnType("TEXT"); + b.Property("ContentEncryptionKey") .IsRequired() .HasColumnType("TEXT"); @@ -1007,9 +1010,6 @@ namespace Bit.SqliteMigrations.Migrations b.Property("CreationDate") .HasColumnType("TEXT"); - b.Property("Date") - .HasColumnType("TEXT"); - b.Property("OrganizationId") .HasColumnType("TEXT"); @@ -1017,6 +1017,12 @@ namespace Bit.SqliteMigrations.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("Id") From d0778a8a7b84f08934f12feee2938296973f5ff2 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:02:10 -0400 Subject: [PATCH 48/85] Clean up OrgnizationIntegrationRequestModel validations and nullable declarations (#6301) * Clean up OrgnizationIntegrationRequestModel validations; remove unnecessary nullable enables * Fix weird line break --- .../OrgnizationIntegrationRequestModel.cs | 57 ++++++++----------- .../Data/EventIntegrations/HecIntegration.cs | 4 +- .../EventIntegrations/IIntegrationMessage.cs | 4 +- .../IntegrationFilterGroup.cs | 4 +- .../IntegrationFilterOperation.cs | 3 +- .../IntegrationFilterRule.cs | 4 +- .../IntegrationHandlerResult.cs | 4 +- .../EventIntegrations/IntegrationMessage.cs | 4 +- .../IntegrationTemplateContext.cs | 4 +- .../EventIntegrations/SlackIntegration.cs | 4 +- .../SlackIntegrationConfiguration.cs | 4 +- .../SlackIntegrationConfigurationDetails.cs | 4 +- .../EventIntegrations/WebhookIntegration.cs | 4 +- .../WebhookIntegrationConfiguration.cs | 4 +- .../WebhookIntegrationConfigurationDetails.cs | 4 +- .../AzureServiceBusEventListenerService.cs | 4 +- ...ureServiceBusIntegrationListenerService.cs | 4 +- .../EventIntegrationEventWriteService.cs | 4 +- .../EventIntegrationHandler.cs | 4 +- .../EventRepositoryHandler.cs | 4 +- .../EventIntegrations/EventRouteService.cs | 4 +- .../IntegrationFilterFactory.cs | 4 +- .../IntegrationFilterService.cs | 4 +- .../RabbitMqEventListenerService.cs | 4 +- .../RabbitMqIntegrationListenerService.cs | 4 +- .../EventIntegrations/RabbitMqService.cs | 4 +- .../SlackIntegrationHandler.cs | 4 +- .../EventIntegrations/SlackService.cs | 4 +- .../WebhookIntegrationHandler.cs | 4 +- ...rganizationIntegrationRequestModelTests.cs | 10 ++-- 30 files changed, 58 insertions(+), 120 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 5fa2e86a90..92d65ab8fe 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -8,9 +8,9 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModel : IValidatableObject { - public string? Configuration { get; set; } + public string? Configuration { get; init; } - public IntegrationType Type { get; set; } + public IntegrationType Type { get; init; } public OrganizationIntegration ToOrganizationIntegration(Guid organizationId) { @@ -33,62 +33,55 @@ public class OrganizationIntegrationRequestModel : IValidatableObject switch (Type) { case IntegrationType.CloudBillingSync or IntegrationType.Scim: - yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) }); + yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]); break; case IntegrationType.Slack: - yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) }); + yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]); break; case IntegrationType.Webhook: - if (string.IsNullOrWhiteSpace(Configuration)) - { - break; - } - if (!IsIntegrationValid()) - { - yield return new ValidationResult( - "Webhook integrations must include valid configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: true)) + yield return r; break; case IntegrationType.Hec: - if (!IsIntegrationValid()) - { - yield return new ValidationResult( - "HEC integrations must include valid configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: false)) + yield return r; break; case IntegrationType.Datadog: - if (!IsIntegrationValid()) - { - yield return new ValidationResult( - "Datadog integrations must include valid configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: false)) + yield return r; break; default: yield return new ValidationResult( $"Integration type '{Type}' is not recognized.", - new[] { nameof(Type) }); + [nameof(Type)]); break; } } - private bool IsIntegrationValid() + private List ValidateConfiguration(bool allowNullOrEmpty) { + var results = new List(); + if (string.IsNullOrWhiteSpace(Configuration)) { - return false; + if (!allowNullOrEmpty) + results.Add(InvalidConfig()); + return results; } try { - var config = JsonSerializer.Deserialize(Configuration); - return config is not null; + if (JsonSerializer.Deserialize(Configuration) is null) + results.Add(InvalidConfig()); } catch { - return false; + results.Add(InvalidConfig()); } + + return results; } + + private static ValidationResult InvalidConfig() => + new(errorMessage: $"Must include valid {typeof(T).Name} configuration.", memberNames: [nameof(Configuration)]); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs index eff9f8e1be..33ae5dadbe 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs index f979b8af0e..7a0962d89a 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs index bb0c2e01ba..276ca3a14b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationFilterGroup { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs index f09df47738..fddf630e26 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs @@ -1,5 +1,4 @@ -#nullable enable -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public enum IntegrationFilterOperation { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs index b9d90a0442..b5f90f5e63 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationFilterRule { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs index d3b0c0d5ac..8db054561b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationHandlerResult { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs index 1861ec4522..11a5229f8c 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 82c236865f..266c810470 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs index e8bfaee303..dc2733c889 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegration(string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs index 2c757aeb76..5b4fae0c76 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegrationConfiguration(string ChannelId); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs index 6c3d4c2fff..d22f43bb92 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegrationConfigurationDetails(string ChannelId, string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs index 84b4b97857..dcda4caa92 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs index 2f5e8d29c1..851bd3f411 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs index 4fa1a67c8e..dba9b1714d 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs index f5eb41c051..91f8fac888 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Azure.Messaging.ServiceBus; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs index 037ae7e647..e415430965 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs index 519f8aeb32..309b4a8409 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 9cd789be76..0a8ab67554 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs index 0fab787589..ee3a2d5db2 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs index df0819b409..a542e75a7b 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs index b90ea8d16e..d28ac910b7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Linq.Expressions; +using System.Linq.Expressions; using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs index 88877c329a..1c8fae4000 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs index 5b089b06a6..430540a2f7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; using RabbitMQ.Client; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs index 59c8782985..b426032c92 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs index 20ae31a113..3e20e34200 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Settings; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 6f55c0cf9c..2d29494afc 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 3f82217830..f17185c4d3 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Web; using Bit.Core.Models.Slack; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index e0c2b66a90..0599f6e9d4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 9565a76822..81927a1bfe 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -84,7 +84,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -114,7 +114,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -130,7 +130,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -160,7 +160,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -176,7 +176,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] From ac718351a8317553359bd07cd3b9c8219b2bfb62 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 9 Sep 2025 20:33:22 +0530 Subject: [PATCH 49/85] Fix UseKeyConnector is set to true when upgrading to Enterprise (#6281) --- src/Api/Billing/Controllers/OrganizationBillingController.cs | 2 +- .../OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 762b06db96..21b17bff67 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -304,7 +304,7 @@ public class OrganizationBillingController( sale.Organization.UsePolicies = plan.HasPolicies; sale.Organization.UseSso = plan.HasSso; sale.Organization.UseResetPassword = plan.HasResetPassword; - sale.Organization.UseKeyConnector = plan.HasKeyConnector; + sale.Organization.UseKeyConnector = plan.HasKeyConnector ? organization.UseKeyConnector : false; sale.Organization.UseScim = plan.HasScim; sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 6e514bfea7..2b39e6cca6 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -265,7 +265,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand organization.UseApi = newPlan.HasApi; organization.UseSso = newPlan.HasSso; organization.UseOrganizationDomains = newPlan.HasOrganizationDomains; - organization.UseKeyConnector = newPlan.HasKeyConnector; + organization.UseKeyConnector = newPlan.HasKeyConnector ? organization.UseKeyConnector : false; organization.UseScim = newPlan.HasScim; organization.UseResetPassword = newPlan.HasResetPassword; organization.SelfHost = newPlan.HasSelfHost; From 3dd5accb56eec5aaf43fb0541272647edbff0343 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:22:42 -0500 Subject: [PATCH 50/85] [PM-24964] Stripe-hosted bank account verification (#6263) * Implement bank account hosted URL verification with webhook handling notification * Fix tests * Run dotnet format * Remove unused VerifyBankAccount operation * Stephon's feedback * Removing unused test * TEMP: Add logging for deployment check * Run dotnet format * fix test * Revert "fix test" This reverts commit b8743ab3b57d93eb12754ac586b0bce100834f48. * Revert "Run dotnet format" This reverts commit 5c861b0b72131b954b244639bf3fa1b4a303515e. * Revert "TEMP: Add logging for deployment check" This reverts commit 0a88acd6a1571a9c3a24657c0e199a5fc18e9a50. * Resolve GetPaymentMethodQuery order of operations --- .../Services/ProviderBillingService.cs | 6 +- .../Services/ProviderBillingServiceTests.cs | 6 +- .../OrganizationBillingVNextController.cs | 14 +- .../VNext/ProviderBillingVNextController.cs | 13 +- src/Billing/Constants/HandledStripeWebhook.cs | 1 + .../Services/IPushNotificationAdapter.cs | 11 + src/Billing/Services/IStripeEventService.cs | 46 +- src/Billing/Services/IStripeFacade.cs | 6 + src/Billing/Services/IStripeWebhookHandler.cs | 2 + .../PaymentSucceededHandler.cs | 76 +-- .../PushNotificationAdapter.cs | 71 ++ .../SetupIntentSucceededHandler.cs | 77 +++ .../Implementations/StripeEventProcessor.cs | 84 +-- .../Implementations/StripeEventService.cs | 219 +++--- .../Services/Implementations/StripeFacade.cs | 8 + .../SubscriptionUpdatedHandler.cs | 11 +- src/Billing/Startup.cs | 2 + src/Core/Billing/Caches/ISetupIntentCache.cs | 7 +- .../SetupIntentDistributedCache.cs | 38 +- .../Queries/GetOrganizationWarningsQuery.cs | 2 +- .../Services/OrganizationBillingService.cs | 2 +- .../Commands/VerifyBankAccountCommand.cs | 62 -- .../Payment/Models/MaskedPaymentMethod.cs | 10 +- .../Payment/Queries/GetPaymentMethodQuery.cs | 58 +- src/Core/Billing/Payment/Registrations.cs | 1 - .../PremiumUserBillingService.cs | 2 +- .../Implementations/SubscriberService.cs | 4 +- src/Core/Models/PushNotification.cs | 11 + .../Platform/Push/IPushNotificationService.cs | 14 - src/Core/Platform/Push/PushType.cs | 12 +- src/Notifications/HubHelpers.cs | 15 + .../SetupIntentSucceededHandlerTests.cs | 242 +++++++ .../Services/StripeEventServiceTests.cs | 637 +++++++++++------- .../SubscriptionUpdatedHandlerTests.cs | 13 +- .../GetOrganizationWarningsQueryTests.cs | 4 +- .../UpdatePaymentMethodCommandTests.cs | 21 +- .../Commands/VerifyBankAccountCommandTests.cs | 81 --- .../Models/MaskedPaymentMethodTests.cs | 4 +- .../Queries/GetPaymentMethodQueryTests.cs | 13 +- .../Services/SubscriberServiceTests.cs | 4 +- .../Push/Engines/AzureQueuePushEngineTests.cs | 25 - .../Platform/Push/Engines/PushTestBase.cs | 15 - 42 files changed, 1136 insertions(+), 814 deletions(-) create mode 100644 src/Billing/Services/IPushNotificationAdapter.cs create mode 100644 src/Billing/Services/Implementations/PushNotificationAdapter.cs create mode 100644 src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs delete mode 100644 src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs create mode 100644 test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs delete mode 100644 test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 5169d6cfd1..398674c7b6 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -636,10 +636,10 @@ public class ProviderBillingService( { case PaymentMethodType.BankAccount: { - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); await stripeAdapter.SetupIntentCancel(setupIntentId, new SetupIntentCancelOptions { CancellationReason = "abandoned" }); - await setupIntentCache.Remove(provider.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): @@ -689,7 +689,7 @@ public class ProviderBillingService( }); } - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 4e811017f9..54c0b82aa9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -1003,7 +1003,7 @@ public class ProviderBillingServiceTests o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) .Throws(); - sutProvider.GetDependency().Get(provider.Id).Returns("setup_intent_id"); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); @@ -1013,7 +1013,7 @@ public class ProviderBillingServiceTests await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => options.CancellationReason == "abandoned")); - await sutProvider.GetDependency().Received(1).Remove(provider.Id); + await sutProvider.GetDependency().Received(1).RemoveSetupIntentForSubscriber(provider.Id); } [Theory, BitAutoData] @@ -1644,7 +1644,7 @@ public class ProviderBillingServiceTests const string setupIntentId = "seti_123"; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index a85dfe11e1..ee98031dbc 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -25,8 +25,7 @@ public class OrganizationBillingVNextController( IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IUpdateBillingAddressCommand updateBillingAddressCommand, - IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [Authorize] [HttpGet("address")] @@ -96,17 +95,6 @@ public class OrganizationBillingVNextController( return Handle(result); } - [Authorize] - [HttpPost("payment-method/verify-bank-account")] - [InjectOrganization] - public async Task VerifyBankAccountAsync( - [BindNever] Organization organization, - [FromBody] VerifyBankAccountRequest request) - { - var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode); - return Handle(result); - } - [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs index b0b39eaf4a..0ea9bad682 100644 --- a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs @@ -23,8 +23,7 @@ public class ProviderBillingVNextController( IGetProviderWarningsQuery getProviderWarningsQuery, IProviderService providerService, IUpdateBillingAddressCommand updateBillingAddressCommand, - IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [HttpGet("address")] [InjectProvider(ProviderUserType.ProviderAdmin)] @@ -97,16 +96,6 @@ public class ProviderBillingVNextController( return Handle(result); } - [HttpPost("payment-method/verify-bank-account")] - [InjectProvider(ProviderUserType.ProviderAdmin)] - public async Task VerifyBankAccountAsync( - [BindNever] Provider provider, - [FromBody] VerifyBankAccountRequest request) - { - var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode); - return Handle(result); - } - [HttpGet("warnings")] [InjectProvider(ProviderUserType.ServiceUser)] public async Task GetWarningsAsync( diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index cbcc2065c3..e9e0c5a16b 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -13,4 +13,5 @@ public static class HandledStripeWebhook public const string PaymentMethodAttached = "payment_method.attached"; public const string CustomerUpdated = "customer.updated"; public const string InvoiceFinalized = "invoice.finalized"; + public const string SetupIntentSucceeded = "setup_intent.succeeded"; } diff --git a/src/Billing/Services/IPushNotificationAdapter.cs b/src/Billing/Services/IPushNotificationAdapter.cs new file mode 100644 index 0000000000..2f74f35eec --- /dev/null +++ b/src/Billing/Services/IPushNotificationAdapter.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; + +namespace Bit.Billing.Services; + +public interface IPushNotificationAdapter +{ + Task NotifyBankAccountVerifiedAsync(Organization organization); + Task NotifyBankAccountVerifiedAsync(Provider provider); + Task NotifyEnabledChangedAsync(Organization organization); +} diff --git a/src/Billing/Services/IStripeEventService.cs b/src/Billing/Services/IStripeEventService.cs index bf242905ee..567d404ba6 100644 --- a/src/Billing/Services/IStripeEventService.cs +++ b/src/Billing/Services/IStripeEventService.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Stripe; +using Stripe; namespace Bit.Billing.Services; @@ -13,12 +10,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the charge object from Stripe. + /// Determines whether to retrieve a fresh copy of the charge object from Stripe. /// Optionally provided to expand the fresh charge object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a charge object. - /// Thrown when is true and Stripe's API returns a null charge object. - Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -26,12 +21,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the customer object from Stripe. + /// Determines whether to retrieve a fresh copy of the customer object from Stripe. /// Optionally provided to expand the fresh customer object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a customer object. - /// Thrown when is true and Stripe's API returns a null customer object. - Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -39,12 +32,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the invoice object from Stripe. + /// Determines whether to retrieve a fresh copy of the invoice object from Stripe. /// Optionally provided to expand the fresh invoice object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an invoice object. - /// Thrown when is true and Stripe's API returns a null invoice object. - Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null); + Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -52,12 +43,21 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the payment method object from Stripe. + /// Determines whether to retrieve a fresh copy of the payment method object from Stripe. /// Optionally provided to expand the fresh payment method object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an payment method object. - /// Thrown when is true and Stripe's API returns a null payment method object. - Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null); + Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List? expand = null); + + /// + /// Extracts the object from the Stripe . When is true, + /// uses the setup intent ID extracted from the event to retrieve the most up-to-update setup intent from Stripe's API + /// and optionally expands it with the provided options. + /// + /// The Stripe webhook event. + /// Determines whether to retrieve a fresh copy of the setup intent object from Stripe. + /// Optionally provided to expand the fresh setup intent object retrieved from Stripe. + /// A Stripe . + Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -65,12 +65,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the subscription object from Stripe. + /// Determines whether to retrieve a fresh copy of the subscription object from Stripe. /// Optionally provided to expand the fresh subscription object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an subscription object. - /// Thrown when is true and Stripe's API returns a null subscription object. - Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null); + Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Ensures that the customer associated with the Stripe is in the correct region for this server. diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 37ba51cc61..280a3aca3c 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -38,6 +38,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, diff --git a/src/Billing/Services/IStripeWebhookHandler.cs b/src/Billing/Services/IStripeWebhookHandler.cs index 59be435489..2619b2f663 100644 --- a/src/Billing/Services/IStripeWebhookHandler.cs +++ b/src/Billing/Services/IStripeWebhookHandler.cs @@ -65,3 +65,5 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler; /// Defines the contract for handling Stripe Invoice Finalized events. /// public interface IInvoiceFinalizedHandler : IStripeWebhookHandler; + +public interface ISetupIntentSucceededHandler : IStripeWebhookHandler; diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 4c256e3d85..a10fa4b3d6 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -3,63 +3,38 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class PaymentSucceededHandler : IPaymentSucceededHandler +public class PaymentSucceededHandler( + ILogger logger, + IStripeEventService stripeEventService, + IStripeFacade stripeFacade, + IProviderRepository providerRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService, + IUserService userService, + IOrganizationEnableCommand organizationEnableCommand, + IPricingClient pricingClient, + IPushNotificationAdapter pushNotificationAdapter) + : IPaymentSucceededHandler { - private readonly ILogger _logger; - private readonly IStripeEventService _stripeEventService; - private readonly IUserService _userService; - private readonly IStripeFacade _stripeFacade; - private readonly IProviderRepository _providerRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IStripeEventUtilityService _stripeEventUtilityService; - private readonly IPushNotificationService _pushNotificationService; - private readonly IOrganizationEnableCommand _organizationEnableCommand; - private readonly IPricingClient _pricingClient; - - public PaymentSucceededHandler( - ILogger logger, - IStripeEventService stripeEventService, - IStripeFacade stripeFacade, - IProviderRepository providerRepository, - IOrganizationRepository organizationRepository, - IStripeEventUtilityService stripeEventUtilityService, - IUserService userService, - IPushNotificationService pushNotificationService, - IOrganizationEnableCommand organizationEnableCommand, - IPricingClient pricingClient) - { - _logger = logger; - _stripeEventService = stripeEventService; - _stripeFacade = stripeFacade; - _providerRepository = providerRepository; - _organizationRepository = organizationRepository; - _stripeEventUtilityService = stripeEventUtilityService; - _userService = userService; - _pushNotificationService = pushNotificationService; - _organizationEnableCommand = organizationEnableCommand; - _pricingClient = pricingClient; - } - /// /// Handles the event type from Stripe. /// /// public async Task HandleAsync(Event parsedEvent) { - var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true); if (!invoice.Paid || invoice.BillingReason != "subscription_create") { return; } - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); + var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId); if (subscription?.Status != StripeSubscriptionStatus.Active) { return; @@ -70,15 +45,15 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler await Task.Delay(5000); } - var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); if (providerId.HasValue) { - var provider = await _providerRepository.GetByIdAsync(providerId.Value); + var provider = await providerRepository.GetByIdAsync(providerId.Value); if (provider == null) { - _logger.LogError( + logger.LogError( "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", parsedEvent.Id, providerId.Value); @@ -86,9 +61,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - var teamsMonthly = await _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); + var teamsMonthly = await pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); - var enterpriseMonthly = await _pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); + var enterpriseMonthly = await pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); var teamsMonthlyLineItem = subscription.Items.Data.FirstOrDefault(item => @@ -100,29 +75,30 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) { - _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", + logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); } } else if (organizationId.HasValue) { - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + var organization = await organizationRepository.GetByIdAsync(organizationId.Value); if (organization == null) { return; } - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id)) { return; } - await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); - await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); + await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + organization = await organizationRepository.GetByIdAsync(organization.Id); + await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!); } else if (userId.HasValue) { @@ -131,7 +107,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } } } diff --git a/src/Billing/Services/Implementations/PushNotificationAdapter.cs b/src/Billing/Services/Implementations/PushNotificationAdapter.cs new file mode 100644 index 0000000000..673ae1415e --- /dev/null +++ b/src/Billing/Services/Implementations/PushNotificationAdapter.cs @@ -0,0 +1,71 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Models; +using Bit.Core.Platform.Push; + +namespace Bit.Billing.Services.Implementations; + +public class PushNotificationAdapter( + IProviderUserRepository providerUserRepository, + IPushNotificationService pushNotificationService) : IPushNotificationAdapter +{ + public Task NotifyBankAccountVerifiedAsync(Organization organization) => + pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.OrganizationBankAccountVerified, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationBankAccountVerifiedPushNotification + { + OrganizationId = organization.Id + }, + ExcludeCurrentContext = false + }); + + public async Task NotifyBankAccountVerifiedAsync(Provider provider) + { + var providerUsers = await providerUserRepository.GetManyByProviderAsync(provider.Id); + var providerAdmins = providerUsers.Where(providerUser => providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Confirmed, + UserId: not null + }).ToList(); + + if (providerAdmins.Count > 0) + { + var tasks = providerAdmins.Select(providerAdmin => pushNotificationService.PushAsync( + new PushNotification + { + Type = PushType.ProviderBankAccountVerified, + Target = NotificationTarget.User, + TargetId = providerAdmin.UserId!.Value, + Payload = new ProviderBankAccountVerifiedPushNotification + { + ProviderId = provider.Id, + AdminId = providerAdmin.UserId!.Value + }, + ExcludeCurrentContext = false + })); + + await Task.WhenAll(tasks); + } + } + + public Task NotifyEnabledChangedAsync(Organization organization) => + pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.SyncOrganizationStatusChanged, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled, + }, + ExcludeCurrentContext = false, + }); +} diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs new file mode 100644 index 0000000000..bc3fa1bd56 --- /dev/null +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf; +using Stripe; +using Event = Stripe.Event; + +namespace Bit.Billing.Services.Implementations; + +public class SetupIntentSucceededHandler( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IPushNotificationAdapter pushNotificationAdapter, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + IStripeEventService stripeEventService) : ISetupIntentSucceededHandler +{ + public async Task HandleAsync(Event parsedEvent) + { + var setupIntent = await stripeEventService.GetSetupIntent( + parsedEvent, + true, + ["payment_method"]); + + if (setupIntent is not + { + PaymentMethod.UsBankAccount: not null + }) + { + return; + } + + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + if (subscriberId == null) + { + return; + } + + var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + + OneOf entity = organization != null ? organization : provider!; + await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod); + } + + private async Task SetPaymentMethodAsync( + OneOf subscriber, + PaymentMethod paymentMethod) + { + var customerId = subscriber.Match( + organization => organization.GatewayCustomerId, + provider => provider.GatewayCustomerId); + + if (string.IsNullOrEmpty(customerId)) + { + return; + } + + await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id, + new PaymentMethodAttachOptions { Customer = customerId }); + + await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethod.Id + } + }); + + await subscriber.Match( + async organization => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(organization), + async provider => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(provider)); + } +} diff --git a/src/Billing/Services/Implementations/StripeEventProcessor.cs b/src/Billing/Services/Implementations/StripeEventProcessor.cs index b0d9cf187d..6db813f70c 100644 --- a/src/Billing/Services/Implementations/StripeEventProcessor.cs +++ b/src/Billing/Services/Implementations/StripeEventProcessor.cs @@ -3,88 +3,64 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class StripeEventProcessor : IStripeEventProcessor +public class StripeEventProcessor( + ILogger logger, + ISubscriptionDeletedHandler subscriptionDeletedHandler, + ISubscriptionUpdatedHandler subscriptionUpdatedHandler, + IUpcomingInvoiceHandler upcomingInvoiceHandler, + IChargeSucceededHandler chargeSucceededHandler, + IChargeRefundedHandler chargeRefundedHandler, + IPaymentSucceededHandler paymentSucceededHandler, + IPaymentFailedHandler paymentFailedHandler, + IInvoiceCreatedHandler invoiceCreatedHandler, + IPaymentMethodAttachedHandler paymentMethodAttachedHandler, + ICustomerUpdatedHandler customerUpdatedHandler, + IInvoiceFinalizedHandler invoiceFinalizedHandler, + ISetupIntentSucceededHandler setupIntentSucceededHandler) + : IStripeEventProcessor { - private readonly ILogger _logger; - private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler; - private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler; - private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler; - private readonly IChargeSucceededHandler _chargeSucceededHandler; - private readonly IChargeRefundedHandler _chargeRefundedHandler; - private readonly IPaymentSucceededHandler _paymentSucceededHandler; - private readonly IPaymentFailedHandler _paymentFailedHandler; - private readonly IInvoiceCreatedHandler _invoiceCreatedHandler; - private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler; - private readonly ICustomerUpdatedHandler _customerUpdatedHandler; - private readonly IInvoiceFinalizedHandler _invoiceFinalizedHandler; - - public StripeEventProcessor( - ILogger logger, - ISubscriptionDeletedHandler subscriptionDeletedHandler, - ISubscriptionUpdatedHandler subscriptionUpdatedHandler, - IUpcomingInvoiceHandler upcomingInvoiceHandler, - IChargeSucceededHandler chargeSucceededHandler, - IChargeRefundedHandler chargeRefundedHandler, - IPaymentSucceededHandler paymentSucceededHandler, - IPaymentFailedHandler paymentFailedHandler, - IInvoiceCreatedHandler invoiceCreatedHandler, - IPaymentMethodAttachedHandler paymentMethodAttachedHandler, - ICustomerUpdatedHandler customerUpdatedHandler, - IInvoiceFinalizedHandler invoiceFinalizedHandler) - { - _logger = logger; - _subscriptionDeletedHandler = subscriptionDeletedHandler; - _subscriptionUpdatedHandler = subscriptionUpdatedHandler; - _upcomingInvoiceHandler = upcomingInvoiceHandler; - _chargeSucceededHandler = chargeSucceededHandler; - _chargeRefundedHandler = chargeRefundedHandler; - _paymentSucceededHandler = paymentSucceededHandler; - _paymentFailedHandler = paymentFailedHandler; - _invoiceCreatedHandler = invoiceCreatedHandler; - _paymentMethodAttachedHandler = paymentMethodAttachedHandler; - _customerUpdatedHandler = customerUpdatedHandler; - _invoiceFinalizedHandler = invoiceFinalizedHandler; - } - public async Task ProcessEventAsync(Event parsedEvent) { switch (parsedEvent.Type) { case HandledStripeWebhook.SubscriptionDeleted: - await _subscriptionDeletedHandler.HandleAsync(parsedEvent); + await subscriptionDeletedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.SubscriptionUpdated: - await _subscriptionUpdatedHandler.HandleAsync(parsedEvent); + await subscriptionUpdatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.UpcomingInvoice: - await _upcomingInvoiceHandler.HandleAsync(parsedEvent); + await upcomingInvoiceHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.ChargeSucceeded: - await _chargeSucceededHandler.HandleAsync(parsedEvent); + await chargeSucceededHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.ChargeRefunded: - await _chargeRefundedHandler.HandleAsync(parsedEvent); + await chargeRefundedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentSucceeded: - await _paymentSucceededHandler.HandleAsync(parsedEvent); + await paymentSucceededHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentFailed: - await _paymentFailedHandler.HandleAsync(parsedEvent); + await paymentFailedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.InvoiceCreated: - await _invoiceCreatedHandler.HandleAsync(parsedEvent); + await invoiceCreatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentMethodAttached: - await _paymentMethodAttachedHandler.HandleAsync(parsedEvent); + await paymentMethodAttachedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.CustomerUpdated: - await _customerUpdatedHandler.HandleAsync(parsedEvent); + await customerUpdatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.InvoiceFinalized: - await _invoiceFinalizedHandler.HandleAsync(parsedEvent); + await invoiceFinalizedHandler.HandleAsync(parsedEvent); + break; + case HandledStripeWebhook.SetupIntentSucceeded: + await setupIntentSucceededHandler.HandleAsync(parsedEvent); break; default: - _logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); + logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); break; } } diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 7eef357e14..03ca8eeb10 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -1,183 +1,122 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Billing.Constants; +using Bit.Billing.Constants; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; using Bit.Core.Settings; using Stripe; namespace Bit.Billing.Services.Implementations; -public class StripeEventService : IStripeEventService +public class StripeEventService( + GlobalSettings globalSettings, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + ISetupIntentCache setupIntentCache, + IStripeFacade stripeFacade) + : IStripeEventService { - private readonly GlobalSettings _globalSettings; - private readonly ILogger _logger; - private readonly IStripeFacade _stripeFacade; - - public StripeEventService( - GlobalSettings globalSettings, - ILogger logger, - IStripeFacade stripeFacade) + public async Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null) { - _globalSettings = globalSettings; - _logger = logger; - _stripeFacade = stripeFacade; - } - - public async Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null) - { - var eventCharge = Extract(stripeEvent); + var charge = Extract(stripeEvent); if (!fresh) { - return eventCharge; + return charge; } - if (string.IsNullOrEmpty(eventCharge.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Charge for Event with ID '{eventId}' because no Charge ID was included in the Event.", stripeEvent.Id); - return eventCharge; - } - - var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand }); - - if (charge == null) - { - throw new Exception( - $"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return charge; + return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand }); } - public async Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventCustomer = Extract(stripeEvent); + var customer = Extract(stripeEvent); if (!fresh) { - return eventCustomer; + return customer; } - if (string.IsNullOrEmpty(eventCustomer.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Customer for Event with ID '{eventId}' because no Customer ID was included in the Event.", stripeEvent.Id); - return eventCustomer; - } - - var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand }); - - if (customer == null) - { - throw new Exception( - $"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return customer; + return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand }); } - public async Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventInvoice = Extract(stripeEvent); + var invoice = Extract(stripeEvent); if (!fresh) { - return eventInvoice; + return invoice; } - if (string.IsNullOrEmpty(eventInvoice.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Invoice for Event with ID '{eventId}' because no Invoice ID was included in the Event.", stripeEvent.Id); - return eventInvoice; - } - - var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand }); - - if (invoice == null) - { - throw new Exception( - $"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return invoice; + return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand }); } - public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, + List? expand = null) { - var eventPaymentMethod = Extract(stripeEvent); + var paymentMethod = Extract(stripeEvent); if (!fresh) { - return eventPaymentMethod; + return paymentMethod; } - if (string.IsNullOrEmpty(eventPaymentMethod.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Payment Method for Event with ID '{eventId}' because no Payment Method ID was included in the Event.", stripeEvent.Id); - return eventPaymentMethod; - } - - var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); - - if (paymentMethod == null) - { - throw new Exception( - $"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return paymentMethod; + return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); } - public async Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventSubscription = Extract(stripeEvent); + var setupIntent = Extract(stripeEvent); if (!fresh) { - return eventSubscription; + return setupIntent; } - if (string.IsNullOrEmpty(eventSubscription.Id)) + return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand }); + } + + public async Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null) + { + var subscription = Extract(stripeEvent); + + if (!fresh) { - _logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id); - return eventSubscription; + return subscription; } - var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand }); - - if (subscription == null) - { - throw new Exception( - $"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return subscription; + return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand }); } public async Task ValidateCloudRegion(Event stripeEvent) { - var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; + var serverRegion = globalSettings.BaseServiceUri.CloudRegion; var customerExpansion = new List { "customer" }; var customerMetadata = stripeEvent.Type switch { HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated => - (await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetSubscription(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded => - (await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetCharge(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.UpcomingInvoice => await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent), - HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => - (await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed + or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => + (await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.PaymentMethodAttached => - (await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.CustomerUpdated => - (await GetCustomer(stripeEvent, true))?.Metadata, + (await GetCustomer(stripeEvent, true)).Metadata, + + HandledStripeWebhook.SetupIntentSucceeded => + await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent), _ => null }; @@ -194,51 +133,69 @@ public class StripeEventService : IStripeEventService /* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer' expansion, we need to use the Customer ID on the event to retrieve the metadata. */ - async Task> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) + async Task?> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) { var invoice = await GetInvoice(localStripeEvent); var customer = !string.IsNullOrEmpty(invoice.CustomerId) - ? await _stripeFacade.GetCustomer(invoice.CustomerId) + ? await stripeFacade.GetCustomer(invoice.CustomerId) : null; return customer?.Metadata; } + + async Task?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent) + { + var setupIntent = await GetSetupIntent(localStripeEvent); + + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + if (subscriberId == null) + { + return null; + } + + var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + if (organization is { GatewayCustomerId: not null }) + { + var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); + return organizationCustomer.Metadata; + } + + var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + if (provider is not { GatewayCustomerId: not null }) + { + return null; + } + + var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); + return providerCustomer.Metadata; + } } private static T Extract(Event stripeEvent) - { - if (stripeEvent.Data.Object is not T type) - { - throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'"); - } - - return type; - } + => stripeEvent.Data.Object is not T type + ? throw new Exception( + $"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'") + : type; private static string GetCustomerRegion(IDictionary customerMetadata) { const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; - if (customerMetadata is null) - { - return null; - } - if (customerMetadata.TryGetValue("region", out var value)) { return value; } - var miscasedRegionKey = customerMetadata.Keys + var incorrectlyCasedRegionKey = customerMetadata.Keys .FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase)); - if (miscasedRegionKey is null) + if (incorrectlyCasedRegionKey is null) { return defaultRegion; } - _ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue); + _ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue); return !string.IsNullOrWhiteSpace(regionValue) ? regionValue diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 726a3e977c..eef7ce009e 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -16,6 +16,7 @@ public class StripeFacade : IStripeFacade private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); private readonly DiscountService _discountService = new(); + private readonly SetupIntentService _setupIntentService = new(); private readonly TestClockService _testClockService = new(); public async Task GetCharge( @@ -53,6 +54,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + public async Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _setupIntentService.GetAsync(setupIntentId, setupIntentGetOptions, requestOptions, cancellationToken); + public async Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index d5fcfb20d4..10630f78f4 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Quartz; @@ -25,7 +24,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; private readonly IOrganizationEnableCommand _organizationEnableCommand; @@ -35,6 +33,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; private readonly ILogger _logger; + private readonly IPushNotificationAdapter _pushNotificationAdapter; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -43,7 +42,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IStripeFacade stripeFacade, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, - IPushNotificationService pushNotificationService, IOrganizationRepository organizationRepository, ISchedulerFactory schedulerFactory, IOrganizationEnableCommand organizationEnableCommand, @@ -52,7 +50,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IFeatureService featureService, IProviderRepository providerRepository, IProviderService providerService, - ILogger logger) + ILogger logger, + IPushNotificationAdapter pushNotificationAdapter) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; @@ -61,7 +60,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; - _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; _providerRepository = providerRepository; _schedulerFactory = schedulerFactory; @@ -72,6 +70,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _providerRepository = providerRepository; _providerService = providerService; _logger = logger; + _pushNotificationAdapter = pushNotificationAdapter; } /// @@ -125,7 +124,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); if (organization != null) { - await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); } break; } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cfbc90c36e..5b464d5ef6 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -73,6 +73,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Identity @@ -111,6 +112,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Add Quartz services first services.AddQuartz(q => diff --git a/src/Core/Billing/Caches/ISetupIntentCache.cs b/src/Core/Billing/Caches/ISetupIntentCache.cs index 0990266239..8e53e8fb09 100644 --- a/src/Core/Billing/Caches/ISetupIntentCache.cs +++ b/src/Core/Billing/Caches/ISetupIntentCache.cs @@ -2,9 +2,8 @@ public interface ISetupIntentCache { - Task Get(Guid subscriberId); - - Task Remove(Guid subscriberId); - + Task GetSetupIntentIdForSubscriber(Guid subscriberId); + Task GetSubscriberIdForSetupIntent(string setupIntentId); + Task RemoveSetupIntentForSubscriber(Guid subscriberId); Task Set(Guid subscriberId, string setupIntentId); } diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs index 432a778762..8833c928fe 100644 --- a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Billing.Caches.Implementations; @@ -10,26 +7,41 @@ public class SetupIntentDistributedCache( [FromKeyedServices("persistent")] IDistributedCache distributedCache) : ISetupIntentCache { - public async Task Get(Guid subscriberId) + public async Task GetSetupIntentIdForSubscriber(Guid subscriberId) { - var cacheKey = GetCacheKey(subscriberId); - + var cacheKey = GetCacheKeyBySubscriberId(subscriberId); return await distributedCache.GetStringAsync(cacheKey); } - public async Task Remove(Guid subscriberId) + public async Task GetSubscriberIdForSetupIntent(string setupIntentId) { - var cacheKey = GetCacheKey(subscriberId); + var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId); + var value = await distributedCache.GetStringAsync(cacheKey); + if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId)) + { + return null; + } + return subscriberId; + } + public async Task RemoveSetupIntentForSubscriber(Guid subscriberId) + { + var cacheKey = GetCacheKeyBySubscriberId(subscriberId); await distributedCache.RemoveAsync(cacheKey); } public async Task Set(Guid subscriberId, string setupIntentId) { - var cacheKey = GetCacheKey(subscriberId); - - await distributedCache.SetStringAsync(cacheKey, setupIntentId); + var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId); + var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId); + await Task.WhenAll( + distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId), + distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString())); } - private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}"; + private static string GetCacheKeyBySetupIntentId(string setupIntentId) => + $"subscriber_id_for_setup_intent_id_{setupIntentId}"; + + private static string GetCacheKeyBySubscriberId(Guid subscriberId) => + $"setup_intent_id_for_subscriber_id_{subscriberId}"; } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 0b0cbd22c6..312623ffa5 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -285,7 +285,7 @@ public class GetOrganizationWarningsQuery( private async Task HasUnverifiedBankAccountAsync( Organization organization) { - var setupIntentId = await setupIntentCache.Get(organization.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id); if (string.IsNullOrEmpty(setupIntentId)) { diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 446f9563f9..ce8a9a877b 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -383,7 +383,7 @@ public class OrganizationBillingService( { case PaymentMethodType.BankAccount: { - await setupIntentCache.Remove(organization.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): diff --git a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs b/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs deleted file mode 100644 index 4f3e38707c..0000000000 --- a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Payment.Models; -using Bit.Core.Entities; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Payment.Commands; - -public interface IVerifyBankAccountCommand -{ - Task> Run( - ISubscriber subscriber, - string descriptorCode); -} - -public class VerifyBankAccountCommand( - ILogger logger, - ISetupIntentCache setupIntentCache, - IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IVerifyBankAccountCommand -{ - private readonly ILogger _logger = logger; - - protected override Conflict DefaultConflict - => new("We had a problem verifying your bank account. Please contact support for assistance."); - - public Task> Run( - ISubscriber subscriber, - string descriptorCode) => HandleAsync(async () => - { - var setupIntentId = await setupIntentCache.Get(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - _logger.LogError( - "{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account", - CommandName, subscriber.Id); - return DefaultConflict; - } - - await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, - new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, - new SetupIntentGetOptions { Expand = ["payment_method"] }); - - var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = setupIntent.PaymentMethodId - } - }); - - return MaskedPaymentMethod.From(paymentMethod.UsBankAccount); - }); -} diff --git a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs index d23ca75025..d30c27ee41 100644 --- a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs +++ b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs @@ -10,7 +10,7 @@ public record MaskedBankAccount { public required string BankName { get; init; } public required string Last4 { get; init; } - public required bool Verified { get; init; } + public string? HostedVerificationUrl { get; init; } public string Type => "bankAccount"; } @@ -39,8 +39,7 @@ public class MaskedPaymentMethod(OneOf new MaskedBankAccount { BankName = bankAccount.BankName, - Last4 = bankAccount.Last4, - Verified = bankAccount.Status == "verified" + Last4 = bankAccount.Last4 }; public static MaskedPaymentMethod From(Card card) => new MaskedCard @@ -61,7 +60,7 @@ public class MaskedPaymentMethod(OneOf new MaskedCard @@ -74,8 +73,7 @@ public class MaskedPaymentMethod(OneOf new MaskedBankAccount { BankName = bankAccount.BankName, - Last4 = bankAccount.Last4, - Verified = true + Last4 = bankAccount.Last4 }; public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email }; diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs index ce8f031a5d..9f9618571e 100644 --- a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -33,6 +33,7 @@ public class GetPaymentMethodQuery( return null; } + // First check for PayPal if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) { var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); @@ -47,6 +48,23 @@ public class GetPaymentMethodQuery( return null; } + // Then check for a bank account pending verification + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); + + if (!string.IsNullOrEmpty(setupIntentId)) + { + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + if (setupIntent.IsUnverifiedBankAccount()) + { + return MaskedPaymentMethod.From(setupIntent); + } + } + + // Then check the default payment method var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null ? customer.InvoiceSettings.DefaultPaymentMethod.Type switch { @@ -61,40 +79,12 @@ public class GetPaymentMethodQuery( return paymentMethod; } - if (customer.DefaultSource != null) + return customer.DefaultSource switch { - paymentMethod = customer.DefaultSource switch - { - Card card => MaskedPaymentMethod.From(card), - BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), - Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), - _ => null - }; - - if (paymentMethod != null) - { - return paymentMethod; - } - } - - var setupIntentId = await setupIntentCache.Get(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - return null; - } - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }); - - // ReSharper disable once ConvertIfStatementToReturnStatement - if (!setupIntent.IsUnverifiedBankAccount()) - { - return null; - } - - return MaskedPaymentMethod.From(setupIntent); + Card card => MaskedPaymentMethod.From(card), + BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), + Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), + _ => null + }; } } diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs index 1cc7914f10..478673d2fc 100644 --- a/src/Core/Billing/Payment/Registrations.cs +++ b/src/Core/Billing/Payment/Registrations.cs @@ -14,7 +14,6 @@ public static class Registrations services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // Queries services.AddTransient(); diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 5b1b717c20..986991ba0a 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -283,7 +283,7 @@ public class PremiumUserBillingService( { case PaymentMethodType.BankAccount: { - await setupIntentCache.Remove(user.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 378e84f15a..1206397d9e 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -858,7 +858,7 @@ public class SubscriberService( ISubscriber subscriber, string descriptorCode) { - var setupIntentId = await setupIntentCache.Get(subscriber.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); if (string.IsNullOrEmpty(setupIntentId)) { @@ -986,7 +986,7 @@ public class SubscriberService( * attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account". * We store the ID of this SetupIntent in the cache when we originally update the payment method. */ - var setupIntentId = await setupIntentCache.Get(subscriberId); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriberId); if (string.IsNullOrEmpty(setupIntentId)) { diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index e235d05b13..c4ae1e2858 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -86,3 +86,14 @@ public class OrganizationCollectionManagementPushNotification public bool LimitCollectionDeletion { get; init; } public bool LimitItemDeletion { get; init; } } + +public class OrganizationBankAccountVerifiedPushNotification +{ + public Guid OrganizationId { get; set; } +} + +public class ProviderBankAccountVerifiedPushNotification +{ + public Guid ProviderId { get; set; } + public Guid AdminId { get; set; } +} diff --git a/src/Core/Platform/Push/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs index 339ce5a917..32a488b827 100644 --- a/src/Core/Platform/Push/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -399,20 +399,6 @@ public interface IPushNotificationService ExcludeCurrentContext = true, }); - Task PushSyncOrganizationStatusAsync(Organization organization) - => PushAsync(new PushNotification - { - Type = PushType.SyncOrganizationStatusChanged, - Target = NotificationTarget.Organization, - TargetId = organization.Id, - Payload = new OrganizationStatusPushNotification - { - OrganizationId = organization.Id, - Enabled = organization.Enabled, - }, - ExcludeCurrentContext = false, - }); - Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) => PushAsync(new PushNotification { diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index 7fcb60b4ef..7765c1aa66 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -4,16 +4,16 @@ namespace Bit.Core.Enums; /// -/// +/// /// /// /// -/// When adding a new enum member you must annotate it with a +/// When adding a new enum member you must annotate it with a /// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced /// in . /// /// -/// You may and are +/// You may and are /// /// public enum PushType : byte @@ -90,4 +90,10 @@ public enum PushType : byte [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] RefreshSecurityTasks = 22, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] + OrganizationBankAccountVerified = 23, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] + ProviderBankAccountVerified = 24 } diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index f49ca96ea4..69d5bdc958 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -106,6 +106,20 @@ public static class HubHelpers await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId)) .SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken); break; + case PushType.OrganizationBankAccountVerified: + var organizationBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId)) + .SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken); + break; + case PushType.ProviderBankAccountVerified: + var providerBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString()) + .SendAsync(_receiveMessageMethod, providerBankAccountVerifiedNotification, cancellationToken); + break; case PushType.Notification: case PushType.NotificationStatus: var notificationData = JsonSerializer.Deserialize>( @@ -144,6 +158,7 @@ public static class HubHelpers .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; default: + logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); break; } } diff --git a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs new file mode 100644 index 0000000000..e9f0d9d0ed --- /dev/null +++ b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs @@ -0,0 +1,242 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; +using Event = Stripe.Event; + +namespace Bit.Billing.Test.Services; + +public class SetupIntentSucceededHandlerTests +{ + private static readonly Event _mockEvent = new() { Id = "evt_test", Type = "setup_intent.succeeded" }; + private static readonly string[] _expand = ["payment_method"]; + + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly IPushNotificationAdapter _pushNotificationAdapter; + private readonly ISetupIntentCache _setupIntentCache; + private readonly IStripeAdapter _stripeAdapter; + private readonly IStripeEventService _stripeEventService; + private readonly SetupIntentSucceededHandler _handler; + + public SetupIntentSucceededHandlerTests() + { + _organizationRepository = Substitute.For(); + _providerRepository = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); + _setupIntentCache = Substitute.For(); + _stripeAdapter = Substitute.For(); + _stripeEventService = Substitute.For(); + + _handler = new SetupIntentSucceededHandler( + _organizationRepository, + _providerRepository, + _pushNotificationAdapter, + _setupIntentCache, + _stripeAdapter, + _stripeEventService); + } + + [Fact] + public async Task HandleAsync_PaymentMethodNotUSBankAccount_Returns() + { + // Arrange + var setupIntent = CreateSetupIntent(hasUSBankAccount: false); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any()); + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_NoSubscriberIdInCache_Returns() + { + // Arrange + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns((Guid?)null); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ValidOrganization_AttachesPaymentMethodAndSendsNotification() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = "cus_test" }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + "pm_test", + Arg.Is(o => o.Customer == organization.GatewayCustomerId)); + + await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(organization); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ValidProvider_AttachesPaymentMethodAndSendsNotification() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = "cus_test" }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + "pm_test", + Arg.Is(o => o.Customer == provider.GatewayCustomerId)); + + await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(provider); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_OrganizationWithoutGatewayCustomerId_DoesNotAttachPaymentMethod() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = null }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ProviderWithoutGatewayCustomerId_DoesNotAttachPaymentMethod() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = null }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true) + { + var paymentMethod = new PaymentMethod + { + Id = "pm_test", + Type = "us_bank_account", + UsBankAccount = hasUSBankAccount ? new PaymentMethodUsBankAccount() : null + }; + + var setupIntent = new SetupIntent + { + Id = "seti_test", + PaymentMethod = paymentMethod + }; + + return setupIntent; + } +} diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index b40e8b9408..68aeab2f44 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -1,8 +1,9 @@ using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Billing.Test.Utilities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -11,6 +12,9 @@ namespace Bit.Billing.Test.Services; public class StripeEventServiceTests { + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly ISetupIntentCache _setupIntentCache; private readonly IStripeFacade _stripeFacade; private readonly StripeEventService _stripeEventService; @@ -20,8 +24,11 @@ public class StripeEventServiceTests var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" }; globalSettings.BaseServiceUri = baseServiceUriSettings; + _organizationRepository = Substitute.For(); + _providerRepository = Substitute.For(); + _setupIntentCache = Substitute.For(); _stripeFacade = Substitute.For(); - _stripeEventService = new StripeEventService(globalSettings, Substitute.For>(), _stripeFacade); + _stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade); } #region GetCharge @@ -29,50 +36,44 @@ public class StripeEventServiceTests public async Task GetCharge_EventNotChargeRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" }); - // Act - var function = async () => await _stripeEventService.GetCharge(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetCharge(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCharge_NotFresh_ReturnsEventCharge() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var mockCharge = new Charge { Id = "ch_test", Amount = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge); // Act var charge = await _stripeEventService.GetCharge(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Charge, charge, true); + Assert.Equal(mockCharge.Id, charge.Id); + Assert.Equal(mockCharge.Amount, charge.Amount); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCharge_Fresh_Expand_ReturnsAPICharge() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var eventCharge = new Charge { Id = "ch_test", Amount = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", eventCharge); - var eventCharge = stripeEvent.Data.Object as Charge; - - var apiCharge = Copy(eventCharge); + var apiCharge = new Charge { Id = "ch_test", Amount = 2000 }; var expand = new List { "customer" }; @@ -90,9 +91,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetCharge( apiCharge.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -101,50 +100,44 @@ public class StripeEventServiceTests public async Task GetCustomer_EventNotCustomerRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" }); - // Act - var function = async () => await _stripeEventService.GetCustomer(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetCustomer(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCustomer_NotFresh_ReturnsEventCustomer() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var mockCustomer = new Customer { Id = "cus_test", Email = "test@example.com" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer); // Act var customer = await _stripeEventService.GetCustomer(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Customer, customer, true); + Assert.Equal(mockCustomer.Id, customer.Id); + Assert.Equal(mockCustomer.Email, customer.Email); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var eventCustomer = new Customer { Id = "cus_test", Email = "test@example.com" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", eventCustomer); - var eventCustomer = stripeEvent.Data.Object as Customer; - - var apiCustomer = Copy(eventCustomer); + var apiCustomer = new Customer { Id = "cus_test", Email = "updated@example.com" }; var expand = new List { "subscriptions" }; @@ -162,9 +155,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetCustomer( apiCustomer.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -173,50 +164,44 @@ public class StripeEventServiceTests public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetInvoice(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetInvoice(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetInvoice_NotFresh_ReturnsEventInvoice() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var mockInvoice = new Invoice { Id = "in_test", AmountDue = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice); // Act var invoice = await _stripeEventService.GetInvoice(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Invoice, invoice, true); + Assert.Equal(mockInvoice.Id, invoice.Id); + Assert.Equal(mockInvoice.AmountDue, invoice.AmountDue); await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var eventInvoice = new Invoice { Id = "in_test", AmountDue = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", eventInvoice); - var eventInvoice = stripeEvent.Data.Object as Invoice; - - var apiInvoice = Copy(eventInvoice); + var apiInvoice = new Invoice { Id = "in_test", AmountDue = 2000 }; var expand = new List { "customer" }; @@ -234,9 +219,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetInvoice( apiInvoice.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -245,50 +228,44 @@ public class StripeEventServiceTests public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetPaymentMethod(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var mockPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod); // Act var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as PaymentMethod, paymentMethod, true); + Assert.Equal(mockPaymentMethod.Id, paymentMethod.Id); + Assert.Equal(mockPaymentMethod.Type, paymentMethod.Type); await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var eventPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", eventPaymentMethod); - var eventPaymentMethod = stripeEvent.Data.Object as PaymentMethod; - - var apiPaymentMethod = Copy(eventPaymentMethod); + var apiPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; var expand = new List { "customer" }; @@ -306,9 +283,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetPaymentMethod( apiPaymentMethod.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -317,50 +292,44 @@ public class StripeEventServiceTests public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetSubscription(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetSubscription(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetSubscription_NotFresh_ReturnsEventSubscription() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test", Status = "active" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); // Act var subscription = await _stripeEventService.GetSubscription(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Subscription, subscription, true); + Assert.Equal(mockSubscription.Id, subscription.Id); + Assert.Equal(mockSubscription.Status, subscription.Status); await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var eventSubscription = new Subscription { Id = "sub_test", Status = "active" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", eventSubscription); - var eventSubscription = stripeEvent.Data.Object as Subscription; - - var apiSubscription = Copy(eventSubscription); + var apiSubscription = new Subscription { Id = "sub_test", Status = "canceled" }; var expand = new List { "customer" }; @@ -378,9 +347,71 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetSubscription( apiSubscription.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); + } + #endregion + + #region GetSetupIntent + [Fact] + public async Task GetSetupIntent_EventNotSetupIntentRelated_ThrowsException() + { + // Arrange + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetSetupIntent(stripeEvent)); + Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(SetupIntent)}'", exception.Message); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSetupIntent_NotFresh_ReturnsEventSetupIntent() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + + // Act + var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent); + + // Assert + Assert.Equal(mockSetupIntent.Id, setupIntent.Id); + Assert.Equal(mockSetupIntent.Status, setupIntent.Status); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSetupIntent_Fresh_Expand_ReturnsAPISetupIntent() + { + // Arrange + var eventSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", eventSetupIntent); + + var apiSetupIntent = new SetupIntent { Id = "seti_test", Status = "requires_action" }; + + var expand = new List { "customer" }; + + _stripeFacade.GetSetupIntent( + apiSetupIntent.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiSetupIntent); + + // Act + var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent, true, expand); + + // Assert + Assert.Equal(apiSetupIntent, setupIntent); + Assert.NotSame(eventSetupIntent, setupIntent); + + await _stripeFacade.Received().GetSetupIntent( + apiSetupIntent.Id, + Arg.Is(options => options.Expand == expand)); } #endregion @@ -389,18 +420,16 @@ public class StripeEventServiceTests public async Task ValidateCloudRegion_SubscriptionUpdated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - - subscription.Customer = customer; + var customer = CreateMockCustomer(); + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -409,28 +438,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_ChargeSucceeded_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var mockCharge = new Charge { Id = "ch_test" }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge); - var charge = Copy(stripeEvent.Data.Object as Charge); - - var customer = await GetCustomerAsync(); - - charge.Customer = customer; + var customer = CreateMockCustomer(); + mockCharge.Customer = customer; _stripeFacade.GetCharge( - charge.Id, + mockCharge.Id, Arg.Any()) - .Returns(charge); + .Returns(mockCharge); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -439,24 +464,21 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCharge( - charge.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockCharge.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_UpcomingInvoice_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceUpcoming); + var mockInvoice = new Invoice { Id = "in_test", CustomerId = "cus_test" }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.upcoming", mockInvoice); - var invoice = Copy(stripeEvent.Data.Object as Invoice); - - var customer = await GetCustomerAsync(); + var customer = CreateMockCustomer(); _stripeFacade.GetCustomer( - invoice.CustomerId, + mockInvoice.CustomerId, Arg.Any()) .Returns(customer); @@ -467,28 +489,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCustomer( - invoice.CustomerId, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockInvoice.CustomerId, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_InvoiceCreated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var mockInvoice = new Invoice { Id = "in_test" }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice); - var invoice = Copy(stripeEvent.Data.Object as Invoice); - - var customer = await GetCustomerAsync(); - - invoice.Customer = customer; + var customer = CreateMockCustomer(); + mockInvoice.Customer = customer; _stripeFacade.GetInvoice( - invoice.Id, + mockInvoice.Id, Arg.Any()) - .Returns(invoice); + .Returns(mockInvoice); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -497,28 +515,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetInvoice( - invoice.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockInvoice.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_PaymentMethodAttached_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var mockPaymentMethod = new PaymentMethod { Id = "pm_test" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod); - var paymentMethod = Copy(stripeEvent.Data.Object as PaymentMethod); - - var customer = await GetCustomerAsync(); - - paymentMethod.Customer = customer; + var customer = CreateMockCustomer(); + mockPaymentMethod.Customer = customer; _stripeFacade.GetPaymentMethod( - paymentMethod.Id, + mockPaymentMethod.Id, Arg.Any()) - .Returns(paymentMethod); + .Returns(mockPaymentMethod); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -527,24 +541,21 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetPaymentMethod( - paymentMethod.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockPaymentMethod.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_CustomerUpdated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); - - var customer = Copy(stripeEvent.Data.Object as Customer); + var mockCustomer = CreateMockCustomer(); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer); _stripeFacade.GetCustomer( - customer.Id, + mockCustomer.Id, Arg.Any()) - .Returns(customer); + .Returns(mockCustomer); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -553,29 +564,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCustomer( - customer.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockCustomer.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = null; - - subscription.Customer = customer; + var customer = new Customer { Id = "cus_test", Metadata = null }; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -584,29 +590,24 @@ public class StripeEventServiceTests Assert.False(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = new Dictionary(); - - subscription.Customer = customer; + var customer = new Customer { Id = "cus_test", Metadata = new Dictionary() }; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -615,32 +616,28 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] - public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue() + public async Task ValidateCloudRegion_MetadataIncorrectlyCasedRegion_ReturnsTrue() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = new Dictionary + var customer = new Customer { - { "Region", "US" } + Id = "cus_test", + Metadata = new Dictionary { { "Region", "US" } } }; - - subscription.Customer = customer; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -649,31 +646,209 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationCustomer_Success() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var organizationId = Guid.NewGuid(); + var organizationCustomerId = "cus_org_test"; + + var mockOrganization = new Core.AdminConsole.Entities.Organization + { + Id = organizationId, + GatewayCustomerId = organizationCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(mockOrganization); + + _stripeFacade.GetCustomer(organizationCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _stripeFacade.Received(1).GetCustomer(organizationCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderCustomer_Success() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var providerId = Guid.NewGuid(); + var providerCustomerId = "cus_provider_test"; + + var mockProvider = new Core.AdminConsole.Entities.Provider.Provider + { + Id = providerId, + GatewayCustomerId = providerCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Core.AdminConsole.Entities.Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(mockProvider); + + _stripeFacade.GetCustomer(providerCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(providerId); + await _providerRepository.Received(1).GetByIdAsync(providerId); + await _stripeFacade.Received(1).GetCustomer(providerCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_NoSubscriberIdInCache_ReturnsFalse() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns((Guid?)null); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.False(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _providerRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationWithoutGatewayCustomerId_ChecksProvider() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var subscriberId = Guid.NewGuid(); + var providerCustomerId = "cus_provider_test"; + + var mockOrganizationWithoutCustomerId = new Core.AdminConsole.Entities.Organization + { + Id = subscriberId, + GatewayCustomerId = null + }; + + var mockProvider = new Core.AdminConsole.Entities.Provider.Provider + { + Id = subscriberId, + GatewayCustomerId = providerCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(subscriberId); + + _organizationRepository.GetByIdAsync(subscriberId) + .Returns(mockOrganizationWithoutCustomerId); + + _providerRepository.GetByIdAsync(subscriberId) + .Returns(mockProvider); + + _stripeFacade.GetCustomer(providerCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(subscriberId); + await _providerRepository.Received(1).GetByIdAsync(subscriberId); + await _stripeFacade.Received(1).GetCustomer(providerCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderWithoutGatewayCustomerId_ReturnsFalse() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var subscriberId = Guid.NewGuid(); + + var mockProviderWithoutCustomerId = new Core.AdminConsole.Entities.Provider.Provider + { + Id = subscriberId, + GatewayCustomerId = null + }; + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(subscriberId); + + _organizationRepository.GetByIdAsync(subscriberId) + .Returns((Core.AdminConsole.Entities.Organization?)null); + + _providerRepository.GetByIdAsync(subscriberId) + .Returns(mockProviderWithoutCustomerId); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.False(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(subscriberId); + await _providerRepository.Received(1).GetByIdAsync(subscriberId); + await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any()); } #endregion - private static T Copy(T input) + private static Event CreateMockEvent(string id, string type, T dataObject) where T : IStripeEntity { - var copy = (T)Activator.CreateInstance(typeof(T)); - - var properties = input.GetType().GetProperties(); - - foreach (var property in properties) + return new Event { - var value = property.GetValue(input); - copy! - .GetType() - .GetProperty(property.Name)! - .SetValue(copy, value); - } - - return copy; + Id = id, + Type = type, + Data = new EventData + { + Object = (IHasObject)dataObject + } + }; } - private static async Task GetCustomerAsync() - => (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer; + private static Customer CreateMockCustomer() + { + return new Customer + { + Id = "cus_test", + Metadata = new Dictionary { { "region", "US" } } + }; + } } diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 0d1f54ecfd..6a7cd7704b 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -11,7 +11,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; @@ -33,7 +32,6 @@ public class SubscriptionUpdatedHandlerTests private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; @@ -42,6 +40,7 @@ public class SubscriptionUpdatedHandlerTests private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; private readonly IScheduler _scheduler; + private readonly IPushNotificationAdapter _pushNotificationAdapter; private readonly SubscriptionUpdatedHandler _sut; public SubscriptionUpdatedHandlerTests() @@ -53,7 +52,6 @@ public class SubscriptionUpdatedHandlerTests _organizationSponsorshipRenewCommand = Substitute.For(); _userService = Substitute.For(); _providerService = Substitute.For(); - _pushNotificationService = Substitute.For(); _organizationRepository = Substitute.For(); var schedulerFactory = Substitute.For(); _organizationEnableCommand = Substitute.For(); @@ -64,6 +62,7 @@ public class SubscriptionUpdatedHandlerTests _providerService = Substitute.For(); var logger = Substitute.For>(); _scheduler = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); schedulerFactory.GetScheduler().Returns(_scheduler); @@ -74,7 +73,6 @@ public class SubscriptionUpdatedHandlerTests _stripeFacade, _organizationSponsorshipRenewCommand, _userService, - _pushNotificationService, _organizationRepository, schedulerFactory, _organizationEnableCommand, @@ -83,7 +81,8 @@ public class SubscriptionUpdatedHandlerTests _featureService, _providerRepository, _providerService, - logger); + logger, + _pushNotificationAdapter); } [Fact] @@ -540,8 +539,8 @@ public class SubscriptionUpdatedHandlerTests .EnableAsync(organizationId); await _organizationService.Received(1) .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); - await _pushNotificationService.Received(1) - .PushSyncOrganizationStatusAsync(organization); + await _pushNotificationAdapter.Received(1) + .NotifyEnabledChangedAsync(organization); } [Fact] diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index c22cc239d8..eefda06149 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -71,7 +71,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns((string?)null); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null); var response = await sutProvider.Sut.Run(organization); @@ -109,7 +109,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is( options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent { diff --git a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs index 8b1f915658..72280c4c77 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs @@ -74,7 +74,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -95,7 +98,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); } @@ -133,7 +136,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -154,7 +160,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _subscriberService.Received(1).CreateStripeCustomer(organization); @@ -199,7 +205,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -220,7 +229,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, Arg.Is(options => diff --git a/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs deleted file mode 100644 index 4be5539cc8..0000000000 --- a/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Payment.Commands; -using Bit.Core.Services; -using Bit.Core.Test.Billing.Extensions; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Payment.Commands; - -public class VerifyBankAccountCommandTests -{ - private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); - private readonly IStripeAdapter _stripeAdapter = Substitute.For(); - private readonly VerifyBankAccountCommand _command; - - public VerifyBankAccountCommandTests() - { - _command = new VerifyBankAccountCommand( - Substitute.For>(), - _setupIntentCache, - _stripeAdapter); - } - - [Fact] - public async Task Run_MakesCorrectInvocations_ReturnsMaskedBankAccount() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - GatewayCustomerId = "cus_123" - }; - - const string setupIntentId = "seti_123"; - - _setupIntentCache.Get(organization.Id).Returns(setupIntentId); - - var setupIntent = new SetupIntent - { - Id = setupIntentId, - PaymentMethodId = "pm_123", - PaymentMethod = - new PaymentMethod - { - Id = "pm_123", - Type = "us_bank_account", - UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } - }, - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, - Status = "requires_action" - }; - - _stripeAdapter.SetupIntentGet(setupIntentId, - Arg.Is(options => options.HasExpansions("payment_method"))).Returns(setupIntent); - - _stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - Arg.Is(options => options.Customer == organization.GatewayCustomerId)) - .Returns(setupIntent.PaymentMethod); - - var result = await _command.Run(organization, "DESCRIPTOR_CODE"); - - Assert.True(result.IsT0); - var maskedPaymentMethod = result.AsT0; - Assert.True(maskedPaymentMethod.IsT0); - var maskedBankAccount = maskedPaymentMethod.AsT0; - Assert.Equal("Chase", maskedBankAccount.BankName); - Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); - - await _stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id, - Arg.Is(options => options.DescriptorCode == "DESCRIPTOR_CODE")); - - await _stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is( - options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId)); - } -} diff --git a/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs b/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs index 39753857d5..21d47f7615 100644 --- a/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs +++ b/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs @@ -13,7 +13,7 @@ public class MaskedPaymentMethodTests { BankName = "Chase", Last4 = "9999", - Verified = true + HostedVerificationUrl = "https://example.com" }; var json = JsonSerializer.Serialize(input); @@ -32,7 +32,7 @@ public class MaskedPaymentMethodTests { BankName = "Chase", Last4 = "9999", - Verified = true + HostedVerificationUrl = "https://example.com" }; var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; diff --git a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs index 8a4475268d..b6b0d596b3 100644 --- a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs +++ b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs @@ -108,7 +108,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); + Assert.Null(maskedBankAccount.HostedVerificationUrl); } [Fact] @@ -142,7 +142,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); + Assert.Null(maskedBankAccount.HostedVerificationUrl); } [Fact] @@ -163,7 +163,7 @@ public class GetPaymentMethodQueryTests Arg.Is(options => options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); - _setupIntentCache.Get(organization.Id).Returns("seti_123"); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter .SetupIntentGet("seti_123", @@ -177,7 +177,10 @@ public class GetPaymentMethodQueryTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }); @@ -189,7 +192,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); } [Fact] diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 600f9d9be2..de8c6aae19 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -670,7 +670,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); @@ -1876,7 +1876,7 @@ public class SubscriberServiceTests PaymentMethodId = "payment_method_id" }; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); var stripeAdapter = sutProvider.GetDependency(); diff --git a/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs index 961d7cd770..9c46211517 100644 --- a/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs +++ b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs @@ -651,31 +651,6 @@ public class AzureQueuePushEngineTests ); } - [Fact] - public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - Enabled = true, - }; - - var expectedPayload = new JsonObject - { - ["Type"] = 18, - ["Payload"] = new JsonObject - { - ["OrganizationId"] = organization.Id, - ["Enabled"] = organization.Enabled, - }, - }; - - await VerifyNotificationAsync( - async sut => await sut.PushSyncOrganizationStatusAsync(organization), - expectedPayload - ); - } - [Fact] public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() { diff --git a/test/Core.Test/Platform/Push/Engines/PushTestBase.cs b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs index 9097028370..e0eeeda97d 100644 --- a/test/Core.Test/Platform/Push/Engines/PushTestBase.cs +++ b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs @@ -413,21 +413,6 @@ public abstract class PushTestBase ); } - [Fact] - public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - Enabled = true, - }; - - await VerifyNotificationAsync( - async sut => await sut.PushSyncOrganizationStatusAsync(organization), - GetPushSyncOrganizationStatusResponsePayload(organization) - ); - } - [Fact] public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() { From 2986a883eb00cb9039f2dbfd7e4fdf4930112d1b Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 9 Sep 2025 13:43:14 -0500 Subject: [PATCH 51/85] [PM-25126] Add Bulk Policy Details (#6256) * Added new bulk get for policy details * Query improvements to avoid unnecessary look-ups. --- .../Repositories/IPolicyRepository.cs | 11 + .../Repositories/PolicyRepository.cs | 15 + .../Repositories/PolicyRepository.cs | 90 ++++ .../PolicyDetails_ReadByUserIdsPolicyType.sql | 83 ++++ .../GetPolicyDetailsByUserIdTests.cs | 16 +- ...olicyDetailsByUserIdsAndPolicyTypeTests.cs | 457 ++++++++++++++++++ ..._PolicyDetails_ReadByUserIdsPolicyType.sql | 73 +++ 7 files changed, 737 insertions(+), 8 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs create mode 100644 util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 2b46c040bb..9f5c7f3fc4 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -44,4 +44,15 @@ public interface IPolicyRepository : IRepository /// You probably do not want to call it directly. /// Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType); + + /// + /// Retrieves policy details for a list of users filtered by the specified policy type. + /// + /// A collection of user identifiers for which the policy details are to be fetched. + /// The type of policy for which the details are required. + /// + /// An asynchronous task that returns a collection of objects containing the policy information + /// associated with the specified users and policy type. + /// + Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType policyType); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs index c93c66c94d..83d5ef6a70 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs @@ -74,6 +74,21 @@ public class PolicyRepository : Repository, IPolicyRepository } } + public async Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType type) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PolicyDetails_ReadByUserIdsPolicyType]", + new + { + UserIds = userIds.ToGuidIdArrayTVP(), + PolicyType = (byte)type + }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 9d25fd5541..72c277f1d7 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -183,4 +183,94 @@ public class PolicyRepository : Repository> GetPolicyDetailsByUserIdsAndPolicyType( + IEnumerable userIds, PolicyType policyType) + { + ArgumentNullException.ThrowIfNull(userIds); + + var userIdsList = userIds.Where(id => id != Guid.Empty).ToList(); + + if (userIdsList.Count == 0) + { + return []; + } + + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + // Get provider relationships + var providerLookup = await (from pu in dbContext.ProviderUsers + join po in dbContext.ProviderOrganizations on pu.ProviderId equals po.ProviderId + where pu.UserId != null && userIdsList.Contains(pu.UserId.Value) + select new { pu.UserId, po.OrganizationId }) + .ToListAsync(); + + // Hashset for lookup + var providerSet = new HashSet<(Guid UserId, Guid OrganizationId)>( + providerLookup.Select(p => (p.UserId!.Value, p.OrganizationId))); + + // Branch 1: Accepted users + var acceptedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + where p.Enabled + && p.Type == policyType + && o.Enabled + && o.UsePolicies + && ou.Status != OrganizationUserStatusType.Invited + && ou.UserId != null + && userIdsList.Contains(ou.UserId.Value) + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = ou.UserId.Value + }).ToListAsync(); + + // Branch 2: Invited users + var invitedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + join u in dbContext.Users on ou.Email equals u.Email + where p.Enabled + && o.Enabled + && o.UsePolicies + && ou.Status == OrganizationUserStatusType.Invited + && userIdsList.Contains(u.Id) + && p.Type == policyType + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = u.Id + }).ToListAsync(); + + // Combine results with provder lookup + var allResults = acceptedUsers.Concat(invitedUsers) + .Select(item => new OrganizationPolicyDetails + { + OrganizationUserId = item.OrganizationUserId, + OrganizationId = item.OrganizationId, + PolicyType = item.PolicyType, + PolicyData = item.PolicyData, + OrganizationUserType = item.OrganizationUserType, + OrganizationUserStatus = item.OrganizationUserStatus, + OrganizationUserPermissionsData = item.OrganizationUserPermissionsData, + UserId = item.UserId, + IsProvider = providerSet.Contains((item.UserId, item.OrganizationId)) + }); + + return allResults.ToList(); + } } diff --git a/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..8686802f87 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,83 @@ +CREATE PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType + ), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType + ), + AllUsers AS ( + -- Combine both user sets + SELECT * FROM AcceptedUsers + UNION + SELECT * FROM InvitedUsers + ), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT + PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId + ) + -- Final result with efficient IsProvider lookup + SELECT + AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL + ON AU.UserId = PL.UserId + AND AU.OrganizationId = PL.OrganizationId +END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs index 07cb82dc02..0a2ddd7387 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs @@ -16,7 +16,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRep public class GetPolicyDetailsByUserIdTests { - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_NonInvitedUsers_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -105,7 +105,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(new Permissions { ManagePolicies = true }, actualPolicyDetails2.GetOrganizationUserCustomPermissions(), strict: true); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_InvitedUser_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -148,7 +148,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_RevokedConfirmedUser_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -192,7 +192,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_RevokedInvitedUser_DoesntReturnPolicies( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -227,7 +227,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_SetsIsProvider( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -283,7 +283,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDisabledOrganizations( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -312,7 +312,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDowngradedOrganizations( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -342,7 +342,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDisabledPolicies( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs new file mode 100644 index 0000000000..9576967a25 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs @@ -0,0 +1,457 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository; + +public class GetPolicyDetailsByUserIdsAndPolicyTypeTests +{ + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoUsersForAnEnterpriseOrgWithTwoFactorEnabled_WhenUsersHaveBeenConfirmedOrAccepted_ThenShouldReturnCorrectPolicyDetailsAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(GetDefaultUser()); + + var user2 = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Data = string.Empty, + Enabled = true + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(GetAcceptedOrganizationUser(organization, user1)); + + var orgUser2 = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user2)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user1.Id, user2.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var result1 = resultsList.First(r => r.UserId == user1.Id); + Assert.Equal(orgUser1.Id, result1.OrganizationUserId); + Assert.Equal(organization.Id, result1.OrganizationId); + Assert.Equal(PolicyType.TwoFactorAuthentication, result1.PolicyType); + Assert.Equal(policy.Data, result1.PolicyData); + Assert.Equal(OrganizationUserStatusType.Accepted, result1.OrganizationUserStatus); + + var result2 = resultsList.First(r => r.UserId == user2.Id); + Assert.Equal(orgUser2.Id, result2.OrganizationUserId); + Assert.Equal(organization.Id, result2.OrganizationId); + Assert.Equal(PolicyType.TwoFactorAuthentication, result2.PolicyType); + Assert.Equal(policy.Data, result2.PolicyData); + Assert.Equal(OrganizationUserStatusType.Confirmed, result2.OrganizationUserStatus); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user1, user2]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoUsersForEnterpriseOrgWithMasterPasswordEnabled_WhenUsersHaveBeenInvited_ThenShouldReturnCorrectPolicyDetailsForInvitedUsersAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(GetDefaultUser()); + + var user2 = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.MasterPassword, + Data = "{\"minComplexity\":4}", + Enabled = true, + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(GetInvitedOrganizationUser(organization, user1)); + + var orgUser2 = await organizationUserRepository.CreateAsync(GetInvitedOrganizationUser(organization, user2)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user1.Id, user2.Id], + PolicyType.MasterPassword); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var result1 = resultsList.First(r => r.UserId == user1.Id); + Assert.Equal(orgUser1.Id, result1.OrganizationUserId); + Assert.Equal(organization.Id, result1.OrganizationId); + Assert.Equal(PolicyType.MasterPassword, result1.PolicyType); + Assert.Equal(OrganizationUserStatusType.Invited, result1.OrganizationUserStatus); + + var result2 = resultsList.First(r => r.UserId == user2.Id); + Assert.Equal(orgUser2.Id, result2.OrganizationUserId); + Assert.Equal(organization.Id, result2.OrganizationId); + Assert.Equal(PolicyType.MasterPassword, result2.PolicyType); + Assert.Equal(OrganizationUserStatusType.Invited, result2.OrganizationUserStatus); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user1, user2]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenConfirmedUserEnterpriseOrgWithPolicyEnabled_WhenUserIsAProvider_ThenShouldContainProviderDataAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.SingleOrg, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + BusinessName = "Test Provider Business", + BusinessAddress1 = "123 Test St", + BusinessAddress2 = "Suite 456", + BusinessAddress3 = "Floor 7", + BusinessCountry = "US", + BusinessTaxNumber = "123456789", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com" + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id + }); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.SingleOrg); + + // Assert + var resultsList = results.ToList(); + Assert.Single(resultsList); + + var result = resultsList.First(); + Assert.True(result.IsProvider); + Assert.Equal(user.Id, result.UserId); + Assert.Equal(organization.Id, result.OrganizationId); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrgWithTwoEnabledPolicies_WhenRequestingTwoFactor_ShouldOnlyReturnInputPolicyType( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + // Create multiple policies + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization)); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.MasterPassword, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act - Request only TwoFactorAuthentication policy + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.All(resultsList, r => Assert.Equal(PolicyType.TwoFactorAuthentication, r.PolicyType)); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrg_WhenSendPolicyIsDisabled_ShouldNotReturnDisabledPoliciesAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + _ = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.DisableSend, + Data = "{}", + Enabled = false // Disabled policy + }); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.DisableSend); + + // Assert + Assert.Empty(results); + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrgWithPolicies_WhenOrgIsDisabled_ThenShouldNotReturnResults( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = true, + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = false, // Disabled organization + }); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.RequireSso, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.RequireSso); + + // Assert + Assert.Empty(results); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenOrganization_WhenNotUsingPolicies_ThenShouldNotReturnResults( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = false, // Not using policies + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = true, + }); + + var policy = await policyRepository.CreateAsync(GetPolicy(PolicyType.PasswordGenerator, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.PasswordGenerator); + + // Assert + Assert.Empty(results); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenOrganization_WhenRequestingWithNoUsers_ShouldReturnEmptyList( + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + new List(), + PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Empty(results); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoOrganizations_WhenUserIsAMemberOfBoth_ShouldReturnResultsForBothOrganizations( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization1 = await CreateEnterpriseOrgAsync(organizationRepository); + var organization2 = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization1)); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization2)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization1, user)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization2, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var organizationIds = resultsList.Select(r => r.OrganizationId).ToList(); + Assert.Contains(organization1.Id, organizationIds); + Assert.Contains(organization2.Id, organizationIds); + } + + private static async Task CreateEnterpriseOrgAsync(IOrganizationRepository orgRepo) + { + return await orgRepo.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = true, + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = true, + }); + } + + private static User GetDefaultUser() => new() + { + Name = $"Test User {Guid.NewGuid()}", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = $"test.api.key.{Guid.NewGuid()}"[..30], + SecurityStamp = Guid.NewGuid().ToString() + }; + + private static OrganizationUser GetAcceptedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }; + + private static OrganizationUser GetConfirmedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + + private static OrganizationUser GetInvitedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = null, // Invited users don't have UserId + Email = user.Email, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + }; + + private static Policy GetPolicy(PolicyType policyType, Organization organization) => new() + { + OrganizationId = organization.Id, + Type = policyType, + Data = "{\"test\": \"value\"}", + Enabled = true + }; +} diff --git a/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql b/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..52c335a790 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType), + AllUsers AS ( + -- Combine both user sets + SELECT * + FROM AcceptedUsers + UNION + SELECT * + FROM InvitedUsers), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId) + -- Final result with efficient IsProvider lookup + SELECT AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL ON AU.UserId = PL.UserId AND AU.OrganizationId = PL.OrganizationId +END From c4f22a45085c6438dd79c1b4080f26ca30ae85d2 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 9 Sep 2025 12:30:58 -0700 Subject: [PATCH 52/85] [PM-25381] Add env variables for controlling refresh token lifetimes (#6276) * add env variables for controlling refresh token lifetimes * fix whitespace * added setting for adjusting refresh token expiration policy * format --- src/Core/Settings/GlobalSettings.cs | 12 ++++++++++++ src/Identity/IdentityServer/ApiClient.cs | 14 +++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 638e1477c1..c6c96cffb9 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -468,6 +468,18 @@ public class GlobalSettings : IGlobalSettings public string RedisConnectionString { get; set; } public string CosmosConnectionString { get; set; } public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug"; + /// + /// Global override for sliding refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// + public int? SlidingRefreshTokenLifetimeSeconds { get; set; } + /// + /// Global override for absolute refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// + public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; } + /// + /// Global override for refresh token expiration policy. False = Sliding (default), True = Absolute. + /// + public bool UseAbsoluteRefreshTokenExpiration { get; set; } = false; } public class DataProtectionSettings diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index fa5003e0dc..61b51797c0 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -18,10 +18,18 @@ public class ApiClient : Client { ClientId = id; AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType }; - RefreshTokenExpiration = TokenExpiration.Sliding; + + // Use global setting: false = Sliding (default), true = Absolute + RefreshTokenExpiration = globalSettings.IdentityServer.UseAbsoluteRefreshTokenExpiration + ? TokenExpiration.Absolute + : TokenExpiration.Sliding; + RefreshTokenUsage = TokenUsage.ReUse; - SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; - AbsoluteRefreshTokenLifetime = 0; // forever + + // Use global setting if provided, otherwise use constructor parameter + SlidingRefreshTokenLifetime = globalSettings.IdentityServer.SlidingRefreshTokenLifetimeSeconds ?? (86400 * refreshTokenSlidingDays); + AbsoluteRefreshTokenLifetime = globalSettings.IdentityServer.AbsoluteRefreshTokenLifetimeSeconds ?? 0; // forever + UpdateAccessTokenClaimsOnRefresh = true; AccessTokenLifetime = 3600 * accessTokenLifetimeHours; AllowOfflineAccess = true; From 4f4b35e4bf1034d0fdf07098f5d418753ca4b312 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:55:31 -0400 Subject: [PATCH 53/85] [deps] Auth: Update DuoUniversal to 1.3.1 (#5862) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 04dd7781bc..a7db90e892 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -30,7 +30,7 @@ - + From 3283e6c1a645769d80ce00f751d8e274b29376a7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:14:44 -0400 Subject: [PATCH 54/85] [deps] Auth: Update webpack to v5.101.3 (#6208) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 48 +++++++++++++-------- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 48 +++++++++++++-------- src/Admin/package.json | 2 +- 4 files changed, 64 insertions(+), 36 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index a6db196d48..6c79f70673 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } }, @@ -441,9 +441,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -687,9 +687,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -699,6 +699,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2198,22 +2211,23 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -2227,7 +2241,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -2317,9 +2331,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 064cf6d656..e62a62c653 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -18,7 +18,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } } diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index b3f19c4792..cda0560d64 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -20,7 +20,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } }, @@ -442,9 +442,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -688,9 +688,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -700,6 +700,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2207,22 +2220,23 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -2236,7 +2250,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -2326,9 +2340,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { diff --git a/src/Admin/package.json b/src/Admin/package.json index 9076a46239..5ab1cf0815 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } } From 48a262ff1eab5aa61f5c7363fc408aab95356b78 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:15:47 -0400 Subject: [PATCH 55/85] [deps] Auth: Update sass to v1.91.0 (#6206) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 8 ++++---- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 8 ++++---- src/Admin/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 6c79f70673..9502ea638d 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" @@ -1873,9 +1873,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", - "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index e62a62c653..28f40f0d25 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,7 +16,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index cda0560d64..aaa5d85aa3 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,7 +18,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" @@ -1874,9 +1874,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", - "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Admin/package.json b/src/Admin/package.json index 5ab1cf0815..89ee1c5358 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" From 5f76804f476c0ecc629140dd4396f2be4588af5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 10 Sep 2025 01:00:07 +0200 Subject: [PATCH 56/85] Improve Swagger OperationIDs for AC (#6236) --- .../Controllers/GroupsController.cs | 34 ++++++- .../OrganizationConnectionsController.cs | 8 +- .../OrganizationDomainController.cs | 10 +- ...ationIntegrationConfigurationController.cs | 8 +- .../OrganizationIntegrationController.cs | 8 +- .../OrganizationUsersController.cs | 93 ++++++++++++++++--- .../Controllers/OrganizationsController.cs | 16 +++- .../Controllers/PoliciesController.cs | 2 +- .../ProviderOrganizationsController.cs | 8 +- .../Controllers/ProviderUsersController.cs | 26 +++++- .../Controllers/ProvidersController.cs | 16 +++- .../OrganizationDomainControllerTests.cs | 6 +- .../OrganizationUsersControllerTests.cs | 2 +- 13 files changed, 202 insertions(+), 35 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index f8e97881cb..4587e54aee 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -163,7 +163,6 @@ public class GroupsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model) { if (!await _currentContext.ManageGroups(orgId)) @@ -237,8 +236,14 @@ public class GroupsController : Controller return new GroupResponseModel(group); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid orgId, Guid id, [FromBody] GroupRequestModel model) + { + return await Put(orgId, id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string orgId, string id) { var group = await _groupRepository.GetByIdAsync(new Guid(id)); @@ -250,8 +255,14 @@ public class GroupsController : Controller await _deleteGroupCommand.DeleteAsync(group); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(string orgId, string id) + { + await Delete(orgId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task BulkDelete([FromBody] GroupBulkRequestModel model) { var groups = await _groupRepository.GetManyByManyIds(model.Ids); @@ -267,9 +278,15 @@ public class GroupsController : Controller await _deleteGroupCommand.DeleteManyAsync(groups); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostBulkDelete([FromBody] GroupBulkRequestModel model) + { + await BulkDelete(model); + } + [HttpDelete("{id}/user/{orgUserId}")] - [HttpPost("{id}/delete-user/{orgUserId}")] - public async Task Delete(string orgId, string id, string orgUserId) + public async Task DeleteUser(string orgId, string id, string orgUserId) { var group = await _groupRepository.GetByIdAsync(new Guid(id)); if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) @@ -279,4 +296,11 @@ public class GroupsController : Controller await _groupService.DeleteUserAsync(group, new Guid(orgUserId)); } + + [HttpPost("{id}/delete-user/{orgUserId}")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteUser(string orgId, string id, string orgUserId) + { + await DeleteUser(orgId, id, orgUserId); + } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs index 79ed2ceabe..776e28d2a3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs @@ -140,7 +140,6 @@ public class OrganizationConnectionsController : Controller } [HttpDelete("{organizationConnectionId}")] - [HttpPost("{organizationConnectionId}/delete")] public async Task DeleteConnection(Guid organizationConnectionId) { var connection = await _organizationConnectionRepository.GetByIdAsync(organizationConnectionId); @@ -158,6 +157,13 @@ public class OrganizationConnectionsController : Controller await _deleteOrganizationConnectionCommand.DeleteAsync(connection); } + [HttpPost("{organizationConnectionId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteConnection(Guid organizationConnectionId) + { + await DeleteConnection(organizationConnectionId); + } + private async Task> GetConnectionsAsync(Guid organizationId, OrganizationConnectionType type) => await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, type); diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index a8882dfaf3..15cfafe240 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -46,7 +46,7 @@ public class OrganizationDomainController : Controller } [HttpGet("{orgId}/domain")] - public async Task> Get(Guid orgId) + public async Task> GetAll(Guid orgId) { await ValidateOrganizationAccessAsync(orgId); @@ -105,7 +105,6 @@ public class OrganizationDomainController : Controller } [HttpDelete("{orgId}/domain/{id}")] - [HttpPost("{orgId}/domain/{id}/remove")] public async Task RemoveDomain(Guid orgId, Guid id) { await ValidateOrganizationAccessAsync(orgId); @@ -119,6 +118,13 @@ public class OrganizationDomainController : Controller await _deleteOrganizationDomainCommand.DeleteAsync(domain); } + [HttpPost("{orgId}/domain/{id}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostRemoveDomain(Guid orgId, Guid id) + { + await RemoveDomain(orgId, id); + } + [AllowAnonymous] [HttpPost("domain/sso/details")] // must be post to accept email cleanly public async Task GetOrgDomainSsoDetails( diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 319fbbe707..ae0f91d355 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -98,7 +98,6 @@ public class OrganizationIntegrationConfigurationController( } [HttpDelete("{configurationId:guid}")] - [HttpPost("{configurationId:guid}/delete")] public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) { if (!await HasPermission(organizationId)) @@ -120,6 +119,13 @@ public class OrganizationIntegrationConfigurationController( await integrationConfigurationRepository.DeleteAsync(configuration); } + [HttpPost("{configurationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + await DeleteAsync(organizationId, integrationId, configurationId); + } + private async Task HasPermission(Guid organizationId) { return await currentContext.OrganizationOwner(organizationId); diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index 7052350c9a..a12492949d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -64,7 +64,6 @@ public class OrganizationIntegrationController( } [HttpDelete("{integrationId:guid}")] - [HttpPost("{integrationId:guid}/delete")] public async Task DeleteAsync(Guid organizationId, Guid integrationId) { if (!await HasPermission(organizationId)) @@ -81,6 +80,13 @@ public class OrganizationIntegrationController( await integrationRepository.DeleteAsync(integration); } + [HttpPost("{integrationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId) + { + await DeleteAsync(organizationId, integrationId); + } + private async Task HasPermission(Guid organizationId) { return await currentContext.OrganizationOwner(organizationId); diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 2b464c24e2..5183b59b00 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -167,7 +167,7 @@ public class OrganizationUsersController : Controller } [HttpGet("")] - public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) + public async Task> GetAll(Guid orgId, bool includeGroups = false, bool includeCollections = false) { var request = new OrganizationUserUserDetailsQueryRequest { @@ -360,7 +360,6 @@ public class OrganizationUsersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] [Authorize] public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) { @@ -436,6 +435,14 @@ public class OrganizationUsersController : Controller collectionsToSave, groupsToSave); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PostPut(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) + { + await Put(orgId, id, model); + } + [HttpPut("{userId}/reset-password-enrollment")] public async Task PutResetPasswordEnrollment(Guid orgId, Guid userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model) { @@ -492,7 +499,6 @@ public class OrganizationUsersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/remove")] [Authorize] public async Task Remove(Guid orgId, Guid id) { @@ -500,8 +506,15 @@ public class OrganizationUsersController : Controller await _removeOrganizationUserCommand.RemoveUserAsync(orgId, id, userId.Value); } + [HttpPost("{id}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task PostRemove(Guid orgId, Guid id) + { + await Remove(orgId, id); + } + [HttpDelete("")] - [HttpPost("remove")] [Authorize] public async Task> BulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { @@ -511,8 +524,15 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } + [HttpPost("remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task> PostBulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRemove(orgId, model); + } + [HttpDelete("{id}/delete-account")] - [HttpPost("{id}/delete-account")] [Authorize] public async Task DeleteAccount(Guid orgId, Guid id) { @@ -525,8 +545,15 @@ public class OrganizationUsersController : Controller await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); } + [HttpPost("{id}/delete-account")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task PostDeleteAccount(Guid orgId, Guid id) + { + await DeleteAccount(orgId, id); + } + [HttpDelete("delete-account")] - [HttpPost("delete-account")] [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { @@ -542,7 +569,14 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } - [HttpPatch("{id}/revoke")] + [HttpPost("delete-account")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task> PostBulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkDeleteAccount(orgId, model); + } + [HttpPut("{id}/revoke")] [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) @@ -550,7 +584,14 @@ public class OrganizationUsersController : Controller await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } - [HttpPatch("revoke")] + [HttpPatch("{id}/revoke")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchRevokeAsync(Guid orgId, Guid id) + { + await RevokeAsync(orgId, id); + } + [HttpPut("revoke")] [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) @@ -558,7 +599,14 @@ public class OrganizationUsersController : Controller return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); } - [HttpPatch("{id}/restore")] + [HttpPatch("revoke")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task> PatchBulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRevokeAsync(orgId, model); + } + [HttpPut("{id}/restore")] [Authorize] public async Task RestoreAsync(Guid orgId, Guid id) @@ -566,7 +614,14 @@ public class OrganizationUsersController : Controller await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); } - [HttpPatch("restore")] + [HttpPatch("{id}/restore")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchRestoreAsync(Guid orgId, Guid id) + { + await RestoreAsync(orgId, id); + } + [HttpPut("restore")] [Authorize] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) @@ -574,7 +629,14 @@ public class OrganizationUsersController : Controller return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); } - [HttpPatch("enable-secrets-manager")] + [HttpPatch("restore")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task> PatchBulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRestoreAsync(orgId, model); + } + [HttpPut("enable-secrets-manager")] [Authorize] public async Task BulkEnableSecretsManagerAsync(Guid orgId, @@ -607,6 +669,15 @@ public class OrganizationUsersController : Controller await _organizationUserRepository.ReplaceManyAsync(orgUsers); } + [HttpPatch("enable-secrets-manager")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchBulkEnableSecretsManagerAsync(Guid orgId, + [FromBody] OrganizationUserBulkRequestModel model) + { + await BulkEnableSecretsManagerAsync(orgId, model); + } + private async Task RestoreOrRevokeUserAsync( Guid orgId, Guid id, diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 17e6a60cd9..590895665d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -225,7 +225,6 @@ public class OrganizationsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) { var orgIdGuid = new Guid(id); @@ -252,6 +251,13 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization, plan); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + { + return await Put(id, model); + } + [HttpPost("{id}/storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorage(string id, [FromBody] StorageRequestModel model) @@ -291,7 +297,6 @@ public class OrganizationsController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string id, [FromBody] SecretVerificationRequestModel model) { var orgIdGuid = new Guid(id); @@ -334,6 +339,13 @@ public class OrganizationsController : Controller await _organizationDeleteCommand.DeleteAsync(organization); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(string id, [FromBody] SecretVerificationRequestModel model) + { + await Delete(id, model); + } + [HttpPost("{id}/delete-recover-token")] [AllowAnonymous] public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model) diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index a80546e2f5..88777f1c30 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -90,7 +90,7 @@ public class PoliciesController : Controller } [HttpGet("")] - public async Task> Get(string orgId) + public async Task> GetAll(string orgId) { var orgIdGuid = new Guid(orgId); if (!await _currentContext.ManagePolicies(orgIdGuid)) diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index f68b036be4..11d302ff86 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -93,7 +93,6 @@ public class ProviderOrganizationsController : Controller } [HttpDelete("{id:guid}")] - [HttpPost("{id:guid}/delete")] public async Task Delete(Guid providerId, Guid id) { if (!_currentContext.ManageProviderOrganizations(providerId)) @@ -112,4 +111,11 @@ public class ProviderOrganizationsController : Controller providerOrganization, organization); } + + [HttpPost("{id:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid providerId, Guid id) + { + await Delete(providerId, id); + } } diff --git a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs index b89f553325..dcf9492605 100644 --- a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs @@ -49,7 +49,7 @@ public class ProviderUsersController : Controller } [HttpGet("")] - public async Task> Get(Guid providerId) + public async Task> GetAll(Guid providerId) { if (!_currentContext.ProviderManageUsers(providerId)) { @@ -155,7 +155,6 @@ public class ProviderUsersController : Controller } [HttpPut("{id:guid}")] - [HttpPost("{id:guid}")] public async Task Put(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -173,8 +172,14 @@ public class ProviderUsersController : Controller await _providerService.SaveUserAsync(model.ToProviderUser(providerUser), userId.Value); } + [HttpPost("{id:guid}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) + { + await Put(providerId, id, model); + } + [HttpDelete("{id:guid}")] - [HttpPost("{id:guid}/delete")] public async Task Delete(Guid providerId, Guid id) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -186,8 +191,14 @@ public class ProviderUsersController : Controller await _providerService.DeleteUsersAsync(providerId, new[] { id }, userId.Value); } + [HttpPost("{id:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid providerId, Guid id) + { + await Delete(providerId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task> BulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -200,4 +211,11 @@ public class ProviderUsersController : Controller return new ListResponseModel(result.Select(r => new ProviderUserBulkResponseModel(r.Item1.Id, r.Item2))); } + + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task> PostBulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) + { + return await BulkDelete(providerId, model); + } } diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index d8bda2ca18..a1815fd3bf 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -53,7 +53,6 @@ public class ProvidersController : Controller } [HttpPut("{id:guid}")] - [HttpPost("{id:guid}")] public async Task Put(Guid id, [FromBody] ProviderUpdateRequestModel model) { if (!_currentContext.ProviderProviderAdmin(id)) @@ -71,6 +70,13 @@ public class ProvidersController : Controller return new ProviderResponseModel(provider); } + [HttpPost("{id:guid}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid id, [FromBody] ProviderUpdateRequestModel model) + { + return await Put(id, model); + } + [HttpPost("{id:guid}/setup")] public async Task Setup(Guid id, [FromBody] ProviderSetupRequestModel model) { @@ -120,7 +126,6 @@ public class ProvidersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { if (!_currentContext.ProviderProviderAdmin(id)) @@ -142,4 +147,11 @@ public class ProvidersController : Controller await _providerService.DeleteAsync(provider); } + + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs index 352f089db7..f81221c605 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs @@ -28,7 +28,7 @@ public class OrganizationDomainControllerTests { sutProvider.GetDependency().ManageSso(orgId).Returns(false); - var requestAction = async () => await sutProvider.Sut.Get(orgId); + var requestAction = async () => await sutProvider.Sut.GetAll(orgId); await Assert.ThrowsAsync(requestAction); } @@ -40,7 +40,7 @@ public class OrganizationDomainControllerTests sutProvider.GetDependency().ManageSso(orgId).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgId).ReturnsNull(); - var requestAction = async () => await sutProvider.Sut.Get(orgId); + var requestAction = async () => await sutProvider.Sut.GetAll(orgId); await Assert.ThrowsAsync(requestAction); } @@ -64,7 +64,7 @@ public class OrganizationDomainControllerTests } }); - var result = await sutProvider.Sut.Get(orgId); + var result = await sutProvider.Sut.GetAll(orgId); Assert.IsType>(result); Assert.Equal(orgId, result.Data.Select(x => x.OrganizationId).FirstOrDefault()); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index cc480d1dcb..2c45385002 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -271,7 +271,7 @@ public class OrganizationUsersControllerTests SutProvider sutProvider) { GetMany_Setup(organizationAbility, organizationUsers, sutProvider); - var response = await sutProvider.Sut.Get(organizationAbility.Id, false, false); + var response = await sutProvider.Sut.GetAll(organizationAbility.Id, false, false); Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); } From 52045b89fada48234ffe2630d3d84fa6ec88e96f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 08:55:21 -0400 Subject: [PATCH 57/85] [deps]: Lock file maintenance (#5876) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 126 ++++++++---------- src/Admin/package-lock.json | 126 ++++++++---------- src/Core/MailTemplates/Mjml/package-lock.json | 48 +++---- 3 files changed, 140 insertions(+), 160 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 9502ea638d..aeefbd69d7 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -34,18 +34,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -58,20 +54,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -80,16 +66,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -455,13 +441,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.10.0" } }, "node_modules/@webassemblyjs/ast": { @@ -794,9 +780,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -814,8 +800,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -834,9 +820,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -988,16 +974,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.215", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", + "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1120,9 +1106,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1254,9 +1240,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -1541,9 +1527,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "dev": true, "license": "MIT" }, @@ -1648,9 +1634,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1668,7 +1654,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2074,24 +2060,28 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", - "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2152,9 +2142,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index aaa5d85aa3..2e3a335598 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -35,18 +35,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -59,20 +55,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -81,16 +67,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -456,13 +442,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.10.0" } }, "node_modules/@webassemblyjs/ast": { @@ -795,9 +781,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -815,8 +801,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -835,9 +821,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -989,16 +975,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.215", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", + "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1121,9 +1107,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1255,9 +1241,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -1542,9 +1528,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "dev": true, "license": "MIT" }, @@ -1649,9 +1635,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1669,7 +1655,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2075,24 +2061,28 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", - "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2161,9 +2151,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json index 30e1e3568c..a78405676f 100644 --- a/src/Core/MailTemplates/Mjml/package-lock.json +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -18,9 +18,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -78,9 +78,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -90,9 +90,9 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -139,9 +139,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -386,9 +386,9 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -402,9 +402,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -1415,9 +1415,9 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1758,9 +1758,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" From d43b00dad9f37432fd1f72139380e95b6f3e15d4 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Wed, 10 Sep 2025 10:13:04 -0400 Subject: [PATCH 58/85] [PM-24279] Add vnext policy endpoint (#6253) --- .../Controllers/PoliciesController.cs | 20 +- .../Models/Request/SavePolicyRequest.cs | 61 ++++ .../Organizations/PolicyResponseModel.cs | 4 + .../Policies/IPostSavePolicySideEffect.cs | 10 + .../Policies/ISavePolicyCommand.cs | 6 + .../Implementations/SavePolicyCommand.cs | 45 ++- .../Policies/Models/EmptyMetadataModel.cs | 6 + .../Policies/Models/IPolicyMetadataModel.cs | 6 + .../OrganizationModelOwnershipPolicyModel.cs | 16 + .../Policies/Models/SavePolicyModel.cs | 8 + .../PolicyServiceCollectionExtensions.cs | 8 +- ...rganizationDataOwnershipPolicyValidator.cs | 54 ++-- .../OrganizationPolicyValidator.cs | 25 +- .../Controllers/PoliciesControllerTests.cs | 214 +++++++++++++ .../Models/Request/SavePolicyRequestTests.cs | 303 ++++++++++++++++++ .../AutoFixture/PolicyUpdateFixtures.cs | 2 +- ...zationDataOwnershipPolicyValidatorTests.cs | 154 +++++---- .../OrganizationPolicyValidatorTests.cs | 18 +- .../Policies/SavePolicyCommandTests.cs | 84 ++++- 19 files changed, 908 insertions(+), 136 deletions(-) create mode 100644 src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs create mode 100644 test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 88777f1c30..ce92321833 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; @@ -30,7 +33,6 @@ namespace Bit.Api.AdminConsole.Controllers; public class PoliciesController : Controller { private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IOrganizationRepository _organizationRepository; @@ -49,7 +51,6 @@ public class PoliciesController : Controller GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IFeatureService featureService, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, ISavePolicyCommand savePolicyCommand) @@ -63,7 +64,6 @@ public class PoliciesController : Controller "OrganizationServiceDataProtector"); _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; - _featureService = featureService; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _savePolicyCommand = savePolicyCommand; } @@ -212,4 +212,18 @@ public class PoliciesController : Controller var policy = await _savePolicyCommand.SaveAsync(policyUpdate); return new PolicyResponseModel(policy); } + + + [HttpPut("{type}/vnext")] + [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] + [Authorize] + public async Task PutVNext(Guid orgId, [FromBody] SavePolicyRequest model) + { + var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); + + var policy = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + + return new PolicyResponseModel(policy); + } + } diff --git a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs new file mode 100644 index 0000000000..fcdc49882b --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request; + +public class SavePolicyRequest +{ + [Required] + public PolicyRequestModel Policy { get; set; } = null!; + + public Dictionary? Metadata { get; set; } + + public async Task ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) + { + var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); + + var updatedPolicy = new PolicyUpdate() + { + Type = Policy.Type!.Value, + OrganizationId = organizationId, + Data = Policy.Data != null ? JsonSerializer.Serialize(Policy.Data) : null, + Enabled = Policy.Enabled.GetValueOrDefault(), + }; + + var metadata = MapToPolicyMetadata(); + + return new SavePolicyModel(updatedPolicy, performedBy, metadata); + } + + private IPolicyMetadataModel MapToPolicyMetadata() + { + if (Metadata == null) + { + return new EmptyMetadataModel(); + } + + return Policy?.Type switch + { + PolicyType.OrganizationDataOwnership => MapToPolicyMetadata(), + _ => new EmptyMetadataModel() + }; + } + + private IPolicyMetadataModel MapToPolicyMetadata() where T : IPolicyMetadataModel, new() + { + try + { + var json = JsonSerializer.Serialize(Metadata); + return CoreHelpers.LoadClassFromJsonData(json); + } + catch + { + return new EmptyMetadataModel(); + } + } +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 9feafce70c..81ca801308 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -10,6 +10,10 @@ namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class PolicyResponseModel : ResponseModel { + public PolicyResponseModel() : base("policy") + { + } + public PolicyResponseModel(Policy policy, string obj = "policy") : base(obj) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs new file mode 100644 index 0000000000..e90945d12d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface IPostSavePolicySideEffect +{ + public Task ExecuteSideEffectsAsync(SavePolicyModel policyRequest, Policy postUpdatedPolicy, + Policy? previousPolicyState); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs index 6ca842686e..73278d77d2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs @@ -6,4 +6,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; public interface ISavePolicyCommand { Task SaveAsync(PolicyUpdate policy); + + /// + /// FIXME: this is a first pass at implementing side effects after the policy has been saved, which was not supported by the validator pattern. + /// However, this needs to be implemented in a policy-agnostic way rather than building out switch statements in the command itself. + /// + Task VNextSaveAsync(SavePolicyModel policyRequest); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index 71212aaf4c..e2bca930d1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; @@ -17,18 +15,20 @@ public class SavePolicyCommand : ISavePolicyCommand private readonly IPolicyRepository _policyRepository; private readonly IReadOnlyDictionary _policyValidators; private readonly TimeProvider _timeProvider; + private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; - public SavePolicyCommand( - IApplicationCacheService applicationCacheService, + public SavePolicyCommand(IApplicationCacheService applicationCacheService, IEventService eventService, IPolicyRepository policyRepository, IEnumerable policyValidators, - TimeProvider timeProvider) + TimeProvider timeProvider, + IPostSavePolicySideEffect postSavePolicySideEffect) { _applicationCacheService = applicationCacheService; _eventService = eventService; _policyRepository = policyRepository; _timeProvider = timeProvider; + _postSavePolicySideEffect = postSavePolicySideEffect; var policyValidatorsDict = new Dictionary(); foreach (var policyValidator in policyValidators) @@ -78,12 +78,28 @@ public class SavePolicyCommand : ISavePolicyCommand return policy; } + public async Task VNextSaveAsync(SavePolicyModel policyRequest) + { + var (_, currentPolicy) = await GetCurrentPolicyStateAsync(policyRequest.PolicyUpdate); + + var policy = await SaveAsync(policyRequest.PolicyUpdate); + + await ExecutePostPolicySaveSideEffectsForSupportedPoliciesAsync(policyRequest, policy, currentPolicy); + + return policy; + } + + private async Task ExecutePostPolicySaveSideEffectsForSupportedPoliciesAsync(SavePolicyModel policyRequest, Policy postUpdatedPolicy, Policy? previousPolicyState) + { + if (postUpdatedPolicy.Type == PolicyType.OrganizationDataOwnership) + { + await _postSavePolicySideEffect.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); + } + } + private async Task RunValidatorAsync(IPolicyValidator validator, PolicyUpdate policyUpdate) { - var savedPolicies = await _policyRepository.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId); - // Note: policies may be missing from this dict if they have never been enabled - var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); - var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); + var (savedPoliciesDict, currentPolicy) = await GetCurrentPolicyStateAsync(policyUpdate); // If enabling this policy - check that all policy requirements are satisfied if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) @@ -127,4 +143,13 @@ public class SavePolicyCommand : ISavePolicyCommand // Run side effects await validator.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); } + + private async Task<(Dictionary savedPoliciesDict, Policy? currentPolicy)> GetCurrentPolicyStateAsync(PolicyUpdate policyUpdate) + { + var savedPolicies = await _policyRepository.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId); + // Note: policies may be missing from this dict if they have never been enabled + var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); + var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); + return (savedPoliciesDict, currentPolicy); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs new file mode 100644 index 0000000000..0c086ac575 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs @@ -0,0 +1,6 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public record EmptyMetadataModel : IPolicyMetadataModel +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs new file mode 100644 index 0000000000..5331524a1d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs @@ -0,0 +1,6 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public interface IPolicyMetadataModel +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs new file mode 100644 index 0000000000..0ff9200d8f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs @@ -0,0 +1,16 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public class OrganizationModelOwnershipPolicyModel : IPolicyMetadataModel +{ + public OrganizationModelOwnershipPolicyModel() + { + } + + public OrganizationModelOwnershipPolicyModel(string? defaultUserCollectionName) + { + DefaultUserCollectionName = defaultUserCollectionName; + } + + public string? DefaultUserCollectionName { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs new file mode 100644 index 0000000000..7c8d5126e8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs @@ -0,0 +1,8 @@ + +using Bit.Core.AdminConsole.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata) +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 12dd3f973d..5433d70410 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class PolicyServiceCollectionExtensions services.AddPolicyValidators(); services.AddPolicyRequirements(); + services.AddPolicySideEffects(); } private static void AddPolicyValidators(this IServiceCollection services) @@ -27,8 +28,11 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - // This validator will be hooked up in https://bitwarden.atlassian.net/browse/PM-24279. - // services.AddScoped(); + } + + private static void AddPolicySideEffects(this IServiceCollection services) + { + services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs index 2471bda647..f4ef6021a7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -1,44 +1,55 @@ -#nullable enable - + using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +/// +/// Please do not extend or expand this validator. We're currently in the process of refactoring our policy validator pattern. +/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution. +/// public class OrganizationDataOwnershipPolicyValidator( IPolicyRepository policyRepository, ICollectionRepository collectionRepository, IEnumerable> factories, - IFeatureService featureService, - ILogger logger) - : OrganizationPolicyValidator(policyRepository, factories) + IFeatureService featureService) + : OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect { - public override PolicyType Type => PolicyType.OrganizationDataOwnership; - - public override IEnumerable RequiredPolicies => []; - - public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); - - public override async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + public async Task ExecuteSideEffectsAsync( + SavePolicyModel policyRequest, + Policy postUpdatedPolicy, + Policy? previousPolicyState) { if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) { return; } - if (currentPolicy?.Enabled != true && policyUpdate.Enabled) + if (policyRequest.Metadata is not OrganizationModelOwnershipPolicyModel metadata) { - await UpsertDefaultCollectionsForUsersAsync(policyUpdate); + return; + } + + if (string.IsNullOrWhiteSpace(metadata.DefaultUserCollectionName)) + { + return; + } + + var isFirstTimeEnabled = postUpdatedPolicy.Enabled && previousPolicyState == null; + var reEnabled = previousPolicyState?.Enabled == false + && postUpdatedPolicy.Enabled; + + if (isFirstTimeEnabled || reEnabled) + { + await UpsertDefaultCollectionsForUsersAsync(policyRequest.PolicyUpdate, metadata.DefaultUserCollectionName); } } - private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate) + private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate, string defaultCollectionName) { var requirements = await GetUserPolicyRequirementsByOrganizationIdAsync(policyUpdate.OrganizationId, policyUpdate.Type); @@ -49,20 +60,13 @@ public class OrganizationDataOwnershipPolicyValidator( if (!userOrgIds.Any()) { - logger.LogError("No UserOrganizationIds found for {OrganizationId}", policyUpdate.OrganizationId); return; } await collectionRepository.UpsertDefaultCollectionsAsync( policyUpdate.OrganizationId, userOrgIds, - GetDefaultUserCollectionName()); + defaultCollectionName); } - private static string GetDefaultUserCollectionName() - { - // TODO: https://bitwarden.atlassian.net/browse/PM-24279 - const string temporaryPlaceHolderValue = "Default"; - return temporaryPlaceHolderValue; - } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs index 33667b829c..15a0b4bb54 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs @@ -1,17 +1,16 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; -public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) : IPolicyValidator + +/// +/// Please do not use this validator. We're currently in the process of refactoring our policy validator pattern. +/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution. +/// +public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) { - public abstract PolicyType Type { get; } - - public abstract IEnumerable RequiredPolicies { get; } - protected async Task> GetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement { var factory = factories.OfType>().SingleOrDefault(); @@ -36,14 +35,4 @@ public abstract class OrganizationPolicyValidator(IPolicyRepository policyReposi return requirements; } - - public abstract Task OnSaveSideEffectsAsync( - PolicyUpdate policyUpdate, - Policy? currentPolicy - ); - - public abstract Task ValidateAsync( - PolicyUpdate policyUpdate, - Policy? currentPolicy - ); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs new file mode 100644 index 0000000000..1efc2f843d --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class PoliciesControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public PoliciesControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled("pm-19467-create-default-location") + .Returns(true); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task PutVNext_OrganizationDataOwnershipPolicy_Success() + { + // Arrange + const PolicyType policyType = PolicyType.OrganizationDataOwnership; + + const string defaultCollectionName = "Test Default Collection"; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + }, + Metadata = new Dictionary + { + { "defaultUserCollectionName", defaultCollectionName } + } + }; + + var (_, admin) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.User); + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + await AssertResponse(); + + await AssertPolicy(); + + await AssertDefaultCollectionCreatedOnlyForUserTypeAsync(); + return; + + async Task AssertDefaultCollectionCreatedOnlyForUserTypeAsync() + { + var collectionRepository = _factory.GetService(); + await AssertUserExpectations(collectionRepository); + await AssertAdminExpectations(collectionRepository); + } + + async Task AssertUserExpectations(ICollectionRepository collectionRepository) + { + var collections = await collectionRepository.GetManyByUserIdAsync(user.UserId!.Value); + var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); + Assert.NotNull(defaultCollection); + Assert.Equal(_organization.Id, defaultCollection.OrganizationId); + } + + async Task AssertAdminExpectations(ICollectionRepository collectionRepository) + { + var collections = await collectionRepository.GetManyByUserIdAsync(admin.UserId!.Value); + var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); + Assert.Null(defaultCollection); + } + + async Task AssertResponse() + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.True(content.Enabled); + Assert.Equal(policyType, content.Type); + Assert.Equal(_organization.Id, content.OrganizationId); + } + + async Task AssertPolicy() + { + var policyRepository = _factory.GetService(); + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + + Assert.NotNull(policy); + Assert.True(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Null(policy.Data); + Assert.Equal(_organization.Id, policy.OrganizationId); + } + } + + [Fact] + public async Task PutVNext_MasterPasswordPolicy_Success() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 10 }, + { "minLength", 12 }, + { "requireUpper", true }, + { "requireLower", false }, + { "requireNumbers", true }, + { "requireSpecial", false }, + { "enforceOnLogin", true } + } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + await AssertResponse(); + + await AssertPolicyDataForMasterPasswordPolicy(); + return; + + async Task AssertPolicyDataForMasterPasswordPolicy() + { + var policyRepository = _factory.GetService(); + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + + AssertPolicy(policy); + AssertMasterPasswordPolicyData(policy); + } + + async Task AssertResponse() + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.True(content.Enabled); + Assert.Equal(policyType, content.Type); + Assert.Equal(_organization.Id, content.OrganizationId); + } + + void AssertPolicy(Policy policy) + { + Assert.NotNull(policy); + Assert.True(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Equal(_organization.Id, policy.OrganizationId); + Assert.NotNull(policy.Data); + } + + void AssertMasterPasswordPolicyData(Policy policy) + { + var resultData = policy.GetDataModel(); + + var json = JsonSerializer.Serialize(request.Policy.Data); + var expectedData = JsonSerializer.Deserialize(json); + AssertHelper.AssertPropertyEqual(resultData, expectedData); + } + } + +} diff --git a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs new file mode 100644 index 0000000000..057680425a --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs @@ -0,0 +1,303 @@ + +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request; + +[SutProviderCustomize] +public class SavePolicyRequestTests +{ + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithValidData_ReturnsCorrectSavePolicyModel( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var testData = new Dictionary { { "test", "value" } }; + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.TwoFactorAuthentication, + Enabled = true, + Data = testData + }, + Metadata = new Dictionary() + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type); + Assert.Equal(organizationId, result.PolicyUpdate.OrganizationId); + Assert.True(result.PolicyUpdate.Enabled); + Assert.NotNull(result.PolicyUpdate.Data); + + var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); + Assert.Equal("value", deserializedData["test"].ToString()); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.True(result!.PerformedBy.IsOrganizationOwnerOrProvider); + + Assert.IsType(result.Metadata); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithNullData_HandlesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(false); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.SingleOrg, + Enabled = false, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Null(result.PolicyUpdate.Data); + Assert.False(result.PolicyUpdate.Enabled); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.False(result!.PerformedBy.IsOrganizationOwnerOrProvider); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithNonOrganizationOwner_HandlesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.SingleOrg, + Enabled = false, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Null(result.PolicyUpdate.Data); + Assert.False(result.PolicyUpdate.Enabled); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.True(result!.PerformedBy.IsOrganizationOwnerOrProvider); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithValidMetadata_ReturnsCorrectMetadata( + Guid organizationId, + Guid userId, + string defaultCollectionName) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = new Dictionary + { + { "defaultUserCollectionName", defaultCollectionName } + } + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.IsType(result.Metadata); + var metadata = (OrganizationModelOwnershipPolicyModel)result.Metadata; + Assert.Equal(defaultCollectionName, metadata.DefaultUserCollectionName); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithNullMetadata_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + private static readonly Dictionary _complexData = new Dictionary + { + { "stringValue", "test" }, + { "numberValue", 42 }, + { "boolValue", true }, + { "arrayValue", new[] { "item1", "item2" } }, + { "nestedObject", new Dictionary { { "nested", "value" } } } + }; + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_ComplexData_SerializesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.ResetPassword, + Enabled = true, + Data = _complexData + }, + Metadata = new Dictionary() + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); + Assert.Equal("test", deserializedData["stringValue"].GetString()); + Assert.Equal(42, deserializedData["numberValue"].GetInt32()); + Assert.True(deserializedData["boolValue"].GetBoolean()); + Assert.Equal(2, deserializedData["arrayValue"].GetArrayLength()); + var array = deserializedData["arrayValue"].EnumerateArray() + .Select(e => e.GetString()) + .ToArray(); + Assert.Contains("item1", array); + Assert.Contains("item2", array); + Assert.True(deserializedData["nestedObject"].TryGetProperty("nested", out var nestedValue)); + Assert.Equal("value", nestedValue.GetString()); + } + + + [Theory, BitAutoData] + public async Task MapToPolicyMetadata_UnknownPolicyType_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.MaximumVaultTimeout, + Enabled = true, + Data = null + }, + Metadata = new Dictionary + { + { "someProperty", "someValue" } + } + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + [Theory, BitAutoData] + public async Task MapToPolicyMetadata_JsonSerializationException_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var errorDictionary = BuildErrorDictionary(); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = errorDictionary + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + private static Dictionary BuildErrorDictionary() + { + var circularDict = new Dictionary(); + circularDict["self"] = circularDict; + return circularDict; + } +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs index 794f6fddf3..4d00476645 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs @@ -18,7 +18,7 @@ internal class PolicyUpdateCustomization(PolicyType type, bool enabled) : ICusto } } -public class PolicyUpdateAttribute(PolicyType type, bool enabled = true) : CustomizeAttribute +public class PolicyUpdateAttribute(PolicyType type = PolicyType.FreeFamiliesSponsorshipPolicy, bool enabled = true) : CustomizeAttribute { public override ICustomization GetCustomization(ParameterInfo parameter) { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index 2569bc6988..a39382382b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -10,7 +10,6 @@ using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -22,9 +21,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests private const string _defaultUserCollectionName = "Default"; [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_FeatureFlagDisabled_DoesNothing( - [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + public async Task ExecuteSideEffectsAsync_FeatureFlagDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, SutProvider sutProvider) { // Arrange @@ -32,95 +32,102 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(false); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( - [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + public async Task ExecuteSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy previousPolicyState, SutProvider sutProvider) { // Arrange - currentPolicy.OrganizationId = policyUpdate.OrganizationId; - policyUpdate.Enabled = true; + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_PolicyBeingDisabled_DoesNothing( + public async Task ExecuteSideEffectsAsync_PolicyBeingDisabled_DoesNothing( [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership)] Policy previousPolicyState, SutProvider sutProvider) { // Arrange - currentPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_WhenNoUsersExist_ShouldLogError( - [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + public async Task ExecuteSideEffectsAsync_WhenNoUsersExist_DoNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, OrganizationDataOwnershipPolicyRequirementFactory factory) { // Arrange - currentPolicy.OrganizationId = policyUpdate.OrganizationId; - policyUpdate.Enabled = true; + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; - var policyRepository = ArrangePolicyRepositoryWithOutUsers(); + var policyRepository = ArrangePolicyRepository([]); var collectionRepository = Substitute.For(); - var logger = Substitute.For>(); - var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + var sut = ArrangeSut(factory, policyRepository, collectionRepository); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act - await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await collectionRepository .DidNotReceive() .UpsertDefaultCollectionsAsync( Arg.Any(), - Arg.Any>(), + Arg.Any>(), Arg.Any()); - const string expectedErrorMessage = "No UserOrganizationIds found for"; - - logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => (o.ToString() ?? "").Contains(expectedErrorMessage)), - Arg.Any(), - Arg.Any>()); + await policyRepository + .Received(1) + .GetPolicyDetailsByOrganizationIdAsync( + policyUpdate.OrganizationId, + PolicyType.OrganizationDataOwnership); } public static IEnumerable ShouldUpsertDefaultCollectionsTestCases() @@ -133,13 +140,13 @@ public class OrganizationDataOwnershipPolicyValidatorTests object?[] WithExistingPolicy() { var organizationId = Guid.NewGuid(); - var policyUpdate = new PolicyUpdate + var postUpdatedPolicy = new Policy { OrganizationId = organizationId, Type = PolicyType.OrganizationDataOwnership, Enabled = true }; - var currentPolicy = new Policy + var previousPolicyState = new Policy { Id = Guid.NewGuid(), OrganizationId = organizationId, @@ -149,51 +156,53 @@ public class OrganizationDataOwnershipPolicyValidatorTests return new object?[] { - policyUpdate, - currentPolicy + postUpdatedPolicy, + previousPolicyState }; } object?[] WithNoExistingPolicy() { - var policyUpdate = new PolicyUpdate + var postUpdatedPolicy = new Policy { OrganizationId = new Guid(), Type = PolicyType.OrganizationDataOwnership, Enabled = true }; - const Policy currentPolicy = null; + const Policy previousPolicyState = null; return new object?[] { - policyUpdate, - currentPolicy + postUpdatedPolicy, + previousPolicyState }; } } - [Theory, BitAutoData] + [Theory] [BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))] - public async Task OnSaveSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + public async Task ExecuteSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + Policy postUpdatedPolicy, + Policy? previousPolicyState, [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy? currentPolicy, [OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable orgPolicyDetails, OrganizationDataOwnershipPolicyRequirementFactory factory) { // Arrange - foreach (var policyDetail in orgPolicyDetails) + var orgPolicyDetailsList = orgPolicyDetails.ToList(); + foreach (var policyDetail in orgPolicyDetailsList) { policyDetail.OrganizationId = policyUpdate.OrganizationId; } - var policyRepository = ArrangePolicyRepository(orgPolicyDetails); + var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList); var collectionRepository = Substitute.For(); - var logger = Substitute.For>(); - var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + var sut = ArrangeSut(factory, policyRepository, collectionRepository); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act - await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await collectionRepository @@ -204,9 +213,40 @@ public class OrganizationDataOwnershipPolicyValidatorTests _defaultUserCollectionName); } - private static IPolicyRepository ArrangePolicyRepositoryWithOutUsers() + private static IEnumerable WhenDefaultCollectionsDoesNotExistTestCases() { - return ArrangePolicyRepository([]); + yield return [new OrganizationModelOwnershipPolicyModel(null)]; + yield return [new OrganizationModelOwnershipPolicyModel("")]; + yield return [new OrganizationModelOwnershipPolicyModel(" ")]; + yield return [new EmptyMetadataModel()]; + } + [Theory] + [BitMemberAutoData(nameof(WhenDefaultCollectionsDoesNotExistTestCases))] + public async Task ExecuteSideEffectsAsync_WhenDefaultCollectionNameIsInvalid_DoesNothing( + IPolicyMetadataModel metadata, + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, + SutProvider sutProvider) + { + // Arrange + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + var policyRequest = new SavePolicyModel(policyUpdate, null, metadata); + + // Act + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) @@ -222,17 +262,15 @@ public class OrganizationDataOwnershipPolicyValidatorTests private static OrganizationDataOwnershipPolicyValidator ArrangeSut( OrganizationDataOwnershipPolicyRequirementFactory factory, IPolicyRepository policyRepository, - ICollectionRepository collectionRepository, - ILogger logger = null!) + ICollectionRepository collectionRepository) { - logger ??= Substitute.For>(); var featureService = Substitute.For(); featureService .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService, logger); + var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService); return sut; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs index aec1230423..bda927f184 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs @@ -1,7 +1,5 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.Repositories; @@ -161,20 +159,6 @@ public class TestOrganizationPolicyValidator : OrganizationPolicyValidator { } - public override PolicyType Type => PolicyType.TwoFactorAuthentication; - - public override IEnumerable RequiredPolicies => []; - - public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - return Task.FromResult(""); - } - - public override Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - return Task.CompletedTask; - } - public async Task> TestGetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index 426389f33c..6b85760794 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -94,8 +94,8 @@ public class SavePolicyCommandTests Substitute.For(), Substitute.For(), [new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()], - Substitute.For() - )); + Substitute.For(), + Substitute.For())); Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message); } @@ -281,6 +281,85 @@ public class SavePolicyCommandTests await AssertPolicyNotSavedAsync(sutProvider); } + [Theory, BitAutoData] + public async Task VNextSaveAsync_OrganizationDataOwnershipPolicy_ExecutesPostSaveSideEffects( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy) + { + // Arrange + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(result); + + await sutProvider.GetDependency() + .Received(1) + .LogPolicyEventAsync(result, EventType.Policy_Updated); + + await sutProvider.GetDependency() + .Received(1) + .ExecuteSideEffectsAsync(savePolicyModel, result, currentPolicy); + } + + [Theory] + [BitAutoData(PolicyType.SingleOrg)] + [BitAutoData(PolicyType.TwoFactorAuthentication)] + public async Task VNextSaveAsync_NonOrganizationDataOwnershipPolicy_DoesNotExecutePostSaveSideEffects( + PolicyType policyType, + Policy currentPolicy, + [PolicyUpdate] PolicyUpdate policyUpdate) + { + // Arrange + policyUpdate.Type = policyType; + currentPolicy.Type = policyType; + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(result); + + await sutProvider.GetDependency() + .Received(1) + .LogPolicyEventAsync(result, EventType.Policy_Updated); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ExecuteSideEffectsAsync(default!, default!, default!); + } + /// /// Returns a new SutProvider with the PolicyValidators registered in the Sut. /// @@ -289,6 +368,7 @@ public class SavePolicyCommandTests return new SutProvider() .WithFakeTimeProvider() .SetDependency(policyValidators ?? []) + .SetDependency(Substitute.For()) .Create(); } From a458db319ea579f412b4760d18bf2c922bee2a86 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:08:22 -0500 Subject: [PATCH 59/85] [PM-25088] - refactor premium purchase endpoint (#6262) * [PM-25088] add feature flag for new premium subscription flow * [PM-25088] refactor premium endpoint * forgot the punctuation change in the test * [PM-25088] - pr feedback * [PM-25088] - pr feedback round two --- .../PaymentMethodTypeValidationAttribute.cs | 13 + .../VNext/AccountBillingVNextController.cs | 17 + .../SelfHostedAccountBillingController.cs | 38 ++ .../MinimalTokenizedPaymentMethodRequest.cs | 25 + .../Payment/TokenizedPaymentMethodRequest.cs | 14 +- .../PremiumCloudHostedSubscriptionRequest.cs | 26 + .../PremiumSelfHostedSubscriptionRequest.cs | 10 + .../Services/OrganizationFactory.cs | 4 +- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 8 + .../Models/TokenizablePaymentMethodType.cs | 14 + ...tePremiumCloudHostedSubscriptionCommand.cs | 308 +++++++++++ ...atePremiumSelfHostedSubscriptionCommand.cs | 67 +++ .../Billing/Services/ILicensingService.cs | 1 + .../Implementations/LicensingService.cs | 8 + .../PremiumUserBillingService.cs | 2 +- .../NoopLicensingService.cs | 5 + src/Core/Constants.cs | 6 + .../Implementations/StripePaymentService.cs | 2 +- .../Services/Implementations/UserService.cs | 6 +- .../Services/SendValidationService.cs | 2 +- .../Services/Implementations/CipherService.cs | 2 +- ...miumCloudHostedSubscriptionCommandTests.cs | 477 ++++++++++++++++++ ...emiumSelfHostedSubscriptionCommandTests.cs | 199 ++++++++ .../Billing/Services/LicensingServiceTests.cs | 75 +++ 25 files changed, 1309 insertions(+), 21 deletions(-) create mode 100644 src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs create mode 100644 src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs create mode 100644 src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs create mode 100644 src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs create mode 100644 src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs diff --git a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs new file mode 100644 index 0000000000..227b454f9f --- /dev/null +++ b/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs @@ -0,0 +1,13 @@ +using Bit.Api.Utilities; + +namespace Bit.Api.Billing.Attributes; + +public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute +{ + private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; + + public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) + { + ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; + } +} diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index e3b702e36d..a996290507 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -1,8 +1,11 @@ #nullable enable using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Core; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -16,6 +19,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [SelfHosted(NotSelfHostedOnly = true)] public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController @@ -61,4 +65,17 @@ public class AccountBillingVNextController( var result = await updatePaymentMethodCommand.Run(user, paymentMethod, billingAddress); return Handle(result); } + + [HttpPost("subscription")] + [RequireFeature(FeatureFlagKeys.PM23385_UseNewPremiumFlow)] + [InjectUser] + public async Task CreateSubscriptionAsync( + [BindNever] User user, + [FromBody] PremiumCloudHostedSubscriptionRequest request) + { + var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain(); + var result = await createPremiumCloudHostedSubscriptionCommand.Run( + user, paymentMethod, billingAddress, additionalStorageGb); + return Handle(result); + } } diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs new file mode 100644 index 0000000000..544753ad0f --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs @@ -0,0 +1,38 @@ +#nullable enable +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Api.Utilities; +using Bit.Core; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("account/billing/vnext/self-host")] +[SelfHosted(SelfHostedOnly = true)] +public class SelfHostedAccountBillingController( + ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController +{ + [HttpPost("license")] + [RequireFeature(FeatureFlagKeys.PM23385_UseNewPremiumFlow)] + [InjectUser] + public async Task UploadLicenseAsync( + [BindNever] User user, + PremiumSelfHostedSubscriptionRequest request) + { + var license = await ApiHelpers.ReadJsonFileFromBody(HttpContext, request.License); + if (license == null) + { + throw new BadRequestException("Invalid license."); + } + var result = await createPremiumSelfHostedSubscriptionCommand.Run(user, license); + return Handle(result); + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..3b50d2bf63 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -0,0 +1,25 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Attributes; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class MinimalTokenizedPaymentMethodRequest +{ + [Required] + [PaymentMethodTypeValidation] + public required string Type { get; set; } + + [Required] + public required string Token { get; set; } + + public TokenizedPaymentMethod ToDomain() + { + return new TokenizedPaymentMethod + { + Type = TokenizablePaymentMethodTypeExtensions.From(Type), + Token = Token + }; + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs index 663e4e7cd2..f540957a1a 100644 --- a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -1,6 +1,6 @@ #nullable enable using System.ComponentModel.DataAnnotations; -using Bit.Api.Utilities; +using Bit.Api.Billing.Attributes; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; @@ -8,8 +8,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment; public class TokenizedPaymentMethodRequest { [Required] - [StringMatches("bankAccount", "card", "payPal", - ErrorMessage = "Payment method type must be one of: bankAccount, card, payPal")] + [PaymentMethodTypeValidation] public required string Type { get; set; } [Required] @@ -21,14 +20,7 @@ public class TokenizedPaymentMethodRequest { var paymentMethod = new TokenizedPaymentMethod { - Type = Type switch - { - "bankAccount" => TokenizablePaymentMethodType.BankAccount, - "card" => TokenizablePaymentMethodType.Card, - "payPal" => TokenizablePaymentMethodType.PayPal, - _ => throw new InvalidOperationException( - $"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}") - }, + Type = TokenizablePaymentMethodTypeExtensions.From(Type), Token = Token }; diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs new file mode 100644 index 0000000000..b958057f5b --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -0,0 +1,26 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class PremiumCloudHostedSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + [Range(0, 99)] + public short AdditionalStorageGb { get; set; } = 0; + + public (TokenizedPaymentMethod, BillingAddress, short) ToDomain() + { + var paymentMethod = TokenizedPaymentMethod.ToDomain(); + var billingAddress = BillingAddress.ToDomain(); + + return (paymentMethod, billingAddress, AdditionalStorageGb); + } +} diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs new file mode 100644 index 0000000000..261544476e --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs @@ -0,0 +1,10 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class PremiumSelfHostedSubscriptionRequest +{ + [Required] + public required IFormFile License { get; set; } +} diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index dbc8f0fa21..afb3931ec4 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -23,7 +23,7 @@ public static class OrganizationFactory PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType), Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats), MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections), - MaxStorageGb = 10240, + MaxStorageGb = Constants.SelfHostedMaxStorageGb, UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies), UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso), UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector), @@ -75,7 +75,7 @@ public static class OrganizationFactory PlanType = license.PlanType, Seats = license.Seats, MaxCollections = license.MaxCollections, - MaxStorageGb = 10240, + MaxStorageGb = Constants.SelfHostedMaxStorageGb, UsePolicies = license.UsePolicies, UseSso = license.UseSso, UseKeyConnector = license.UseKeyConnector, diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 2be88902c8..131adfedf8 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -79,6 +79,7 @@ public static class StripeConstants public static class Prices { public const string StoragePlanPersonal = "personal-storage-gb-annually"; + public const string PremiumAnnually = "premium-annually"; } public static class ProrationBehavior diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 147e96105a..b4e37f0151 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Payment; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -30,6 +31,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); + services.AddPremiumCommands(); services.AddTransient(); } @@ -39,4 +41,10 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); } + + private static void AddPremiumCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } } diff --git a/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs index d27a924360..c198ec8230 100644 --- a/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs +++ b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs @@ -6,3 +6,17 @@ public enum TokenizablePaymentMethodType Card, PayPal } + +public static class TokenizablePaymentMethodTypeExtensions +{ + public static TokenizablePaymentMethodType From(string type) + { + return type switch + { + "bankAccount" => TokenizablePaymentMethodType.BankAccount, + "card" => TokenizablePaymentMethodType.Card, + "payPal" => TokenizablePaymentMethodType.PayPal, + _ => throw new InvalidOperationException($"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}") + }; + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs new file mode 100644 index 0000000000..8a73f31880 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -0,0 +1,308 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Braintree; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; +using Customer = Stripe.Customer; +using Subscription = Stripe.Subscription; + +namespace Bit.Core.Billing.Premium.Commands; + +using static Utilities; + +/// +/// Creates a premium subscription for a cloud-hosted user with Stripe payment processing. +/// Handles customer creation, payment method setup, and subscription creation. +/// +public interface ICreatePremiumCloudHostedSubscriptionCommand +{ + /// + /// Creates a premium cloud-hosted subscription for the specified user. + /// + /// The user to create the premium subscription for. Must not already be a premium user. + /// The tokenized payment method containing the payment type and token for billing. + /// The billing address information required for tax calculation and customer creation. + /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). + /// A billing command result indicating success or failure with appropriate error details. + Task> Run( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb); +} + +public class CreatePremiumCloudHostedSubscriptionCommand( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserService userService, + IPushNotificationService pushNotificationService, + ILogger logger) + : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand +{ + private static readonly List _expand = ["tax"]; + private readonly ILogger _logger = logger; + + public Task> Run( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("Already a premium user."); + } + + if (additionalStorageGb < 0) + { + return new BadRequest("Additional storage must be greater than 0."); + } + + var customer = string.IsNullOrEmpty(user.GatewayCustomerId) + ? await CreateCustomerAsync(user, paymentMethod, billingAddress) + : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); + + customer = await ReconcileBillingLocationAsync(customer, billingAddress); + + var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); + + switch (paymentMethod) + { + case { Type: TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: + case { Type: not TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Active: + { + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + break; + } + } + + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + user.GatewaySubscriptionId = subscription.Id; + user.MaxStorageGb = (short)(1 + additionalStorageGb); + user.LicenseKey = CoreHelpers.SecureRandomString(20); + user.RevisionDate = DateTime.UtcNow; + + await userService.SaveUserAsync(user); + await pushNotificationService.PushSyncVaultAsync(user.Id); + + return new None(); + }); + + private async Task CreateCustomerAsync(User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + var subscriberName = user.SubscriberName(); + var customerCreateOptions = new CustomerCreateOptions + { + Address = new AddressOptions + { + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + PostalCode = billingAddress.PostalCode, + State = billingAddress.State, + Country = billingAddress.Country + }, + Description = user.Name, + Email = user.Email, + Expand = _expand, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = user.SubscriberType(), + Value = subscriberName.Length <= 30 + ? subscriberName + : subscriberName[..30] + } + ] + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + [StripeConstants.MetadataKeys.UserId] = user.Id.ToString() + }, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + + var braintreeCustomerId = ""; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (paymentMethod.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + var setupIntent = + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) + .FirstOrDefault(); + + if (setupIntent == null) + { + _logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id); + throw new BillingException(); + } + + await setupIntentCache.Set(user.Id, setupIntent.Id); + break; + } + case TokenizablePaymentMethodType.Card: + { + customerCreateOptions.PaymentMethod = paymentMethod.Token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; + break; + } + case TokenizablePaymentMethodType.PayPal: + { + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); + customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + break; + } + default: + { + _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); + throw new BillingException(); + } + } + + try + { + return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + } + catch + { + await Revert(); + throw; + } + + async Task Revert() + { + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (paymentMethod.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + await setupIntentCache.Remove(user.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } + } + } + + private async Task ReconcileBillingLocationAsync( + Customer customer, + BillingAddress billingAddress) + { + /* + * If the customer was previously set up with credit, which does not require a billing location, + * we need to update the customer on the fly before we start the subscription. + */ + if (customer is { Address: { Country: not null and not "", PostalCode: not null and not "" } }) + { + return customer; + } + + var options = new CustomerUpdateOptions + { + Address = new AddressOptions + { + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + PostalCode = billingAddress.PostalCode, + State = billingAddress.State, + Country = billingAddress.Country + }, + Expand = _expand, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + } + + private async Task CreateSubscriptionAsync( + Guid userId, + Customer customer, + int? storage) + { + var subscriptionItemOptionsList = new List + { + new () + { + Price = StripeConstants.Prices.PremiumAnnually, + Quantity = 1 + } + }; + + if (storage is > 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = StripeConstants.Prices.StoragePlanPersonal, + Quantity = storage + }); + } + + var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; + + var subscriptionCreateOptions = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }, + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = customer.Id, + Items = subscriptionItemOptionsList, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.UserId] = userId.ToString() + }, + PaymentBehavior = usingPayPal + ? StripeConstants.PaymentBehavior.DefaultIncomplete + : null, + OffSession = true + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + + if (usingPayPal) + { + await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false + }); + } + + return subscription; + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs new file mode 100644 index 0000000000..7546149ab6 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs @@ -0,0 +1,67 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Creates a premium subscription for a self-hosted user. +/// Validates the license and applies premium benefits including storage limits based on the license terms. +/// +public interface ICreatePremiumSelfHostedSubscriptionCommand +{ + /// + /// Creates a premium self-hosted subscription for the specified user using the provided license. + /// + /// The user to create the premium subscription for. Must not already be a premium user. + /// The user license containing the premium subscription details and verification data. Must be valid and usable by the specified user. + /// A billing command result indicating success or failure with appropriate error details. + Task> Run(User user, UserLicense license); +} + +public class CreatePremiumSelfHostedSubscriptionCommand( + ILicensingService licensingService, + IUserService userService, + IPushNotificationService pushNotificationService, + ILogger logger) + : BaseBillingCommand(logger), ICreatePremiumSelfHostedSubscriptionCommand +{ + public Task> Run( + User user, + UserLicense license) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("Already a premium user."); + } + + if (!licensingService.VerifyLicense(license)) + { + return new BadRequest("Invalid license."); + } + + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage)) + { + return new BadRequest(exceptionMessage); + } + + await licensingService.WriteUserLicenseAsync(user, license); + + user.Premium = true; + user.RevisionDate = DateTime.UtcNow; + user.MaxStorageGb = Core.Constants.SelfHostedMaxStorageGb; + user.LicenseKey = license.LicenseKey; + user.PremiumExpirationDate = license.Expires; + + await userService.SaveUserAsync(user); + await pushNotificationService.PushSyncVaultAsync(user.Id); + + return new None(); + }); +} diff --git a/src/Core/Billing/Services/ILicensingService.cs b/src/Core/Billing/Services/ILicensingService.cs index b6ada998a7..cd9847ea39 100644 --- a/src/Core/Billing/Services/ILicensingService.cs +++ b/src/Core/Billing/Services/ILicensingService.cs @@ -26,4 +26,5 @@ public interface ILicensingService SubscriptionInfo subscriptionInfo); Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); + Task WriteUserLicenseAsync(User user, UserLicense license); } diff --git a/src/Core/Billing/Services/Implementations/LicensingService.cs b/src/Core/Billing/Services/Implementations/LicensingService.cs index 81a52158ce..6f0cdec8f5 100644 --- a/src/Core/Billing/Services/Implementations/LicensingService.cs +++ b/src/Core/Billing/Services/Implementations/LicensingService.cs @@ -389,4 +389,12 @@ public class LicensingService : ILicensingService var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } + + public async Task WriteUserLicenseAsync(User user, UserLicense license) + { + var dir = $"{_globalSettings.LicenseDirectory}/user"; + Directory.CreateDirectory(dir); + await using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json")); + await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); + } } diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 986991ba0a..9db18278b6 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -304,7 +304,7 @@ public class PremiumUserBillingService( { new () { - Price = "premium-annually", + Price = StripeConstants.Prices.PremiumAnnually, Quantity = 1 } }; diff --git a/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs index a54ba3546a..b27e21a7c9 100644 --- a/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs @@ -73,4 +73,9 @@ public class NoopLicensingService : ILicensingService { return Task.FromResult(null); } + + public Task WriteUserLicenseAsync(User user, UserLicense license) + { + return Task.CompletedTask; + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 69003ee253..cba060427c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -10,6 +10,11 @@ public static class Constants public const int BypassFiltersEventId = 12482444; public const int FailedSecretVerificationDelay = 2000; + /// + /// Self-hosted max storage limit in GB (10 TB). + /// + public const short SelfHostedMaxStorageGb = 10240; + // File size limits - give 1 MB extra for cushion. // Note: if request size limits are changed, 'client_max_body_size' // in nginx/proxy.conf may also need to be updated accordingly. @@ -166,6 +171,7 @@ public static class FeatureFlagKeys public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; + public const string PM23385_UseNewPremiumFlow = "pm-23385-use-new-premium-flow"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ec45944bd2..5b68906d8a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -906,7 +906,7 @@ public class StripePaymentService : IPaymentService new() { Quantity = 1, - Plan = "premium-annually" + Plan = StripeConstants.Prices.PremiumAnnually }, new() diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 16e298d177..386cb8c3d2 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -44,8 +44,6 @@ namespace Bit.Core.Services; public class UserService : UserManager, IUserService { - private const string PremiumPlanId = "premium-annually"; - private readonly IUserRepository _userRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; @@ -930,7 +928,7 @@ public class UserService : UserManager, IUserService if (_globalSettings.SelfHosted) { - user.MaxStorageGb = 10240; // 10 TB + user.MaxStorageGb = Constants.SelfHostedMaxStorageGb; user.LicenseKey = license.LicenseKey; user.PremiumExpirationDate = license.Expires; } @@ -989,7 +987,7 @@ public class UserService : UserManager, IUserService user.Premium = license.Premium; user.RevisionDate = DateTime.UtcNow; - user.MaxStorageGb = _globalSettings.SelfHosted ? 10240 : license.MaxStorageGb; // 10 TB + user.MaxStorageGb = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : license.MaxStorageGb; user.LicenseKey = license.LicenseKey; user.PremiumExpirationDate = license.Expires; await SaveUserAsync(user); diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs index c6dd3b1dc9..c545c8b35f 100644 --- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -125,7 +125,7 @@ public class SendValidationService : ISendValidationService { // Users that get access to file storage/premium from their organization get the default // 1 GB max storage. - short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1; + short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1; storageBytesRemaining = user.StorageBytesRemaining(limit); } } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 2a4cc6c137..e0b121fdd3 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -933,7 +933,7 @@ public class CipherService : ICipherService // Users that get access to file storage/premium from their organization get the default // 1 GB max storage. storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? (short)10240 : (short)1); + _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1); } } else if (cipher.OrganizationId.HasValue) diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs new file mode 100644 index 0000000000..e808fb10b0 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -0,0 +1,477 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture.Attributes; +using Braintree; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using Address = Stripe.Address; +using StripeCustomer = Stripe.Customer; +using StripeSubscription = Stripe.Subscription; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class CreatePremiumCloudHostedSubscriptionCommandTests +{ + private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IGlobalSettings _globalSettings = Substitute.For(); + private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserService _userService = Substitute.For(); + private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + private readonly CreatePremiumCloudHostedSubscriptionCommand _command; + + public CreatePremiumCloudHostedSubscriptionCommandTests() + { + var baseServiceUri = Substitute.For(); + baseServiceUri.CloudRegion.Returns("US"); + _globalSettings.BaseServiceUri.Returns(baseServiceUri); + + _command = new CreatePremiumCloudHostedSubscriptionCommand( + _braintreeGateway, + _globalSettings, + _setupIntentCache, + _stripeAdapter, + _subscriberService, + _userService, + _pushNotificationService, + Substitute.For>()); + } + + [Theory, BitAutoData] + public async Task Run_UserAlreadyPremium_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Already a premium user.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_NegativeStorageAmount_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, -1); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Additional storage must be greater than 0.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_ValidPaymentMethodTypes_BankAccount_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; // Ensure no existing customer ID + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.BankAccount; + paymentMethod.Token = "bank_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + var mockSetupIntent = Substitute.For(); + mockSetupIntent.Id = "seti_123"; + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.SetupIntentList(Arg.Any()).Returns(Task.FromResult(new List { mockSetupIntent })); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_ValidPaymentMethodTypes_Card_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_ValidPaymentMethodTypes_PayPal_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.PayPal; + paymentMethod.Token = "paypal_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_ValidRequestWithAdditionalStorage_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + const short additionalStorage = 2; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + + // Assert + Assert.True(result.IsT0); + Assert.True(user.Premium); + Assert.Equal((short)(1 + additionalStorage), user.MaxStorageGb); + Assert.NotNull(user.LicenseKey); + Assert.Equal(20, user.LicenseKey.Length); + Assert.NotEqual(default, user.RevisionDate); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_PayPalWithIncompleteSubscription_SetsPremiumTrue( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + user.PremiumExpirationDate = null; + paymentMethod.Type = TokenizablePaymentMethodType.PayPal; + paymentMethod.Token = "paypal_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "incomplete"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + Assert.True(user.Premium); + Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_NonPayPalWithActiveSubscription_SetsPremiumTrue( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + Assert.True(user.Premium); + Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_SubscriptionStatusDoesNotMatchPatterns_DoesNotSetPremium( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + user.PremiumExpirationDate = null; + paymentMethod.Type = TokenizablePaymentMethodType.PayPal; + paymentMethod.Token = "paypal_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; // PayPal + active doesn't match pattern + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + Assert.False(user.Premium); + Assert.Null(user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_BankAccountWithNoSetupIntentFound_ReturnsUnhandled( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.BankAccount; + paymentMethod.Token = "bank_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "incomplete"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + _stripeAdapter.SetupIntentList(Arg.Any()) + .Returns(Task.FromResult(new List())); // Empty list - no setup intent found + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs new file mode 100644 index 0000000000..6dfd620e45 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs @@ -0,0 +1,199 @@ +using System.Security.Claims; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class CreatePremiumSelfHostedSubscriptionCommandTests +{ + private readonly ILicensingService _licensingService = Substitute.For(); + private readonly IUserService _userService = Substitute.For(); + private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + private readonly CreatePremiumSelfHostedSubscriptionCommand _command; + + public CreatePremiumSelfHostedSubscriptionCommandTests() + { + _command = new CreatePremiumSelfHostedSubscriptionCommand( + _licensingService, + _userService, + _pushNotificationService, + Substitute.For>()); + } + + [Fact] + public async Task Run_UserAlreadyPremium_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = true + }; + + var license = new UserLicense + { + LicenseKey = "test_key", + Expires = DateTime.UtcNow.AddYears(1) + }; + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Already a premium user.", badRequest.Response); + } + + [Fact] + public async Task Run_InvalidLicense_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = false + }; + + var license = new UserLicense + { + LicenseKey = "invalid_key", + Expires = DateTime.UtcNow.AddYears(1) + }; + + _licensingService.VerifyLicense(license).Returns(false); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Invalid license.", badRequest.Response); + } + + [Fact] + public async Task Run_LicenseCannotBeUsed_EmailNotVerified_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = false, + Email = "test@example.com", + EmailVerified = false + }; + + var license = new UserLicense + { + LicenseKey = "test_key", + Expires = DateTime.UtcNow.AddYears(1), + Token = "valid_token" + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("Email", "test@example.com") + })); + + _licensingService.VerifyLicense(license).Returns(true); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Contains("The user's email is not verified.", badRequest.Response); + } + + [Fact] + public async Task Run_LicenseCannotBeUsed_EmailMismatch_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = false, + Email = "user@example.com", + EmailVerified = true + }; + + var license = new UserLicense + { + LicenseKey = "test_key", + Expires = DateTime.UtcNow.AddYears(1), + Token = "valid_token" + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("Email", "license@example.com") + })); + + _licensingService.VerifyLicense(license).Returns(true); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Contains("The user's email does not match the license email.", badRequest.Response); + } + + [Fact] + public async Task Run_ValidRequest_Success() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new User + { + Id = userId, + Premium = false, + Email = "test@example.com", + EmailVerified = true + }; + + var license = new UserLicense + { + LicenseKey = "test_key_12345", + Expires = DateTime.UtcNow.AddYears(1), + Token = "valid_token" + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("Email", "test@example.com") + })); + + _licensingService.VerifyLicense(license).Returns(true); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT0); + + // Verify user was updated correctly + Assert.True(user.Premium); + Assert.NotNull(user.LicenseKey); + Assert.Equal(license.LicenseKey, user.LicenseKey); + Assert.NotEqual(default, user.RevisionDate); + + // Verify services were called + await _licensingService.Received(1).WriteUserLicenseAsync(user, license); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } +} diff --git a/test/Core.Test/Billing/Services/LicensingServiceTests.cs b/test/Core.Test/Billing/Services/LicensingServiceTests.cs index f33bda2164..cc160dec71 100644 --- a/test/Core.Test/Billing/Services/LicensingServiceTests.cs +++ b/test/Core.Test/Billing/Services/LicensingServiceTests.cs @@ -1,8 +1,10 @@ using System.Text.Json; using AutoFixture; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Bit.Core.Settings; using Bit.Core.Test.Billing.AutoFixture; using Bit.Test.Common.AutoFixture; @@ -16,6 +18,8 @@ public class LicensingServiceTests { private static string licenseFilePath(Guid orgId) => Path.Combine(OrganizationLicenseDirectory.Value, $"{orgId}.json"); + private static string userLicenseFilePath(Guid userId) => + Path.Combine(UserLicenseDirectory.Value, $"{userId}.json"); private static string LicenseDirectory => Path.GetDirectoryName(OrganizationLicenseDirectory.Value); private static Lazy OrganizationLicenseDirectory => new(() => { @@ -26,6 +30,15 @@ public class LicensingServiceTests } return directory; }); + private static Lazy UserLicenseDirectory => new(() => + { + var directory = Path.Combine(Path.GetTempPath(), "user"); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + return directory; + }); public static SutProvider GetSutProvider() { @@ -57,4 +70,66 @@ public class LicensingServiceTests Directory.Delete(OrganizationLicenseDirectory.Value, true); } } + + [Theory, BitAutoData] + public async Task WriteUserLicense_CreatesFileWithCorrectContent(User user, UserLicense license) + { + // Arrange + var sutProvider = GetSutProvider(); + var expectedFilePath = userLicenseFilePath(user.Id); + + try + { + // Act + await sutProvider.Sut.WriteUserLicenseAsync(user, license); + + // Assert + Assert.True(File.Exists(expectedFilePath)); + var fileContent = await File.ReadAllTextAsync(expectedFilePath); + var actualLicense = JsonSerializer.Deserialize(fileContent); + + Assert.Equal(license.LicenseKey, actualLicense.LicenseKey); + Assert.Equal(license.Id, actualLicense.Id); + Assert.Equal(license.Expires, actualLicense.Expires); + } + finally + { + // Cleanup + if (Directory.Exists(UserLicenseDirectory.Value)) + { + Directory.Delete(UserLicenseDirectory.Value, true); + } + } + } + + [Theory, BitAutoData] + public async Task WriteUserLicense_CreatesDirectoryIfNotExists(User user, UserLicense license) + { + // Arrange + var sutProvider = GetSutProvider(); + + // Ensure directory doesn't exist + if (Directory.Exists(UserLicenseDirectory.Value)) + { + Directory.Delete(UserLicenseDirectory.Value, true); + } + + try + { + // Act + await sutProvider.Sut.WriteUserLicenseAsync(user, license); + + // Assert + Assert.True(Directory.Exists(UserLicenseDirectory.Value)); + Assert.True(File.Exists(userLicenseFilePath(user.Id))); + } + finally + { + // Cleanup + if (Directory.Exists(UserLicenseDirectory.Value)) + { + Directory.Delete(UserLicenseDirectory.Value, true); + } + } + } } From e57569ad572baeed24366fbb2b9c13eebe9f08b7 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 10 Sep 2025 14:17:45 -0400 Subject: [PATCH 60/85] Alter Integration Template processing to remove keys when encountering null values (#6309) --- .../IntegrationTemplateContext.cs | 5 +- .../Utilities/IntegrationTemplateProcessor.cs | 14 ++- .../IntegrationTemplateContextTests.cs | 102 ++++++++++++++++++ .../IntegrationTemplateProcessorTests.cs | 15 +-- 4 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 266c810470..79a30c3a02 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -23,6 +24,8 @@ public class IntegrationTemplateContext(EventMessage eventMessage) public Guid? GroupId => Event.GroupId; public Guid? PolicyId => Event.PolicyId; + public string EventMessage => JsonSerializer.Serialize(Event); + public User? User { get; set; } public string? UserName => User?.Name; public string? UserEmail => User?.Email; diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index dceeea85f4..b561e58a86 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,6 +1,5 @@ #nullable enable -using System.Text.Json; using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -20,15 +19,14 @@ public static partial class IntegrationTemplateProcessor return TokenRegex().Replace(template, match => { var propertyName = match.Groups[1].Value; - if (propertyName == "EventMessage") + var property = type.GetProperty(propertyName); + + if (property == null) { - return JsonSerializer.Serialize(values); - } - else - { - var property = type.GetProperty(propertyName); - return property?.GetValue(values)?.ToString() ?? match.Value; + return match.Value; // Return unknown keys as keys - i.e. #Key# } + + return property?.GetValue(values)?.ToString() ?? ""; }); } diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs new file mode 100644 index 0000000000..930b04121c --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs @@ -0,0 +1,102 @@ +#nullable enable +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationTemplateContextTests +{ + [Theory, BitAutoData] + public void EventMessage_ReturnsSerializedJsonOfEvent(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage: eventMessage); + var expected = JsonSerializer.Serialize(eventMessage); + + Assert.Equal(expected, sut.EventMessage); + } + + [Theory, BitAutoData] + public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = user }; + + Assert.Equal(user.Name, sut.UserName); + } + + [Theory, BitAutoData] + public void UserName_WhenUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = null }; + + Assert.Null(sut.UserName); + } + + [Theory, BitAutoData] + public void UserEmail_WhenUserIsSet_ReturnsEmail(EventMessage eventMessage, User user) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = user }; + + Assert.Equal(user.Email, sut.UserEmail); + } + + [Theory, BitAutoData] + public void UserEmail_WhenUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = null }; + + Assert.Null(sut.UserEmail); + } + + [Theory, BitAutoData] + public void ActingUserName_WhenActingUserIsSet_ReturnsName(EventMessage eventMessage, User actingUser) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = actingUser }; + + Assert.Equal(actingUser.Name, sut.ActingUserName); + } + + [Theory, BitAutoData] + public void ActingUserName_WhenActingUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = null }; + + Assert.Null(sut.ActingUserName); + } + + [Theory, BitAutoData] + public void ActingUserEmail_WhenActingUserIsSet_ReturnsEmail(EventMessage eventMessage, User actingUser) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = actingUser }; + + Assert.Equal(actingUser.Email, sut.ActingUserEmail); + } + + [Theory, BitAutoData] + public void ActingUserEmail_WhenActingUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = null }; + + Assert.Null(sut.ActingUserEmail); + } + + [Theory, BitAutoData] + public void OrganizationName_WhenOrganizationIsSet_ReturnsDisplayName(EventMessage eventMessage, Organization organization) + { + var sut = new IntegrationTemplateContext(eventMessage) { Organization = organization }; + + Assert.Equal(organization.DisplayName(), sut.OrganizationName); + } + + [Theory, BitAutoData] + public void OrganizationName_WhenOrganizationIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { Organization = null }; + + Assert.Null(sut.OrganizationName); + } +} diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs index 105b65d0da..d9df9486b6 100644 --- a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -1,6 +1,5 @@ #nullable enable -using System.Text.Json; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Models.Data; using Bit.Test.Common.AutoFixture.Attributes; @@ -41,22 +40,12 @@ public class IntegrationTemplateProcessorTests } [Theory, BitAutoData] - public void ReplaceTokens_WithEventMessageToken_ReplacesWithSerializedJson(EventMessage eventMessage) - { - var template = "#EventMessage#"; - var expected = $"{JsonSerializer.Serialize(eventMessage)}"; - var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); - - Assert.Equal(expected, result); - } - - [Theory, BitAutoData] - public void ReplaceTokens_WithNullProperty_LeavesTokenUnchanged(EventMessage eventMessage) + public void ReplaceTokens_WithNullProperty_InsertsEmptyString(EventMessage eventMessage) { eventMessage.UserId = null; var template = "Event #Type#, User (id: #UserId#)."; - var expected = $"Event {eventMessage.Type}, User (id: #UserId#)."; + var expected = $"Event {eventMessage.Type}, User (id: )."; var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); Assert.Equal(expected, result); From 04cb7820a67dc0411b709fb1f6f9ffd73e6a79d0 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Wed, 10 Sep 2025 16:34:10 -0500 Subject: [PATCH 61/85] [PM-25088] Fix collision with PM-24964 (#6312) `ISetupIntentCache.Remove` (used in #6262) was renamed to `RemoveSetupIntentForSubscriber` with 3dd5acc in #6263. --- .../Commands/CreatePremiumCloudHostedSubscriptionCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 8a73f31880..1227cdc034 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -204,7 +204,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( { case TokenizablePaymentMethodType.BankAccount: { - await setupIntentCache.Remove(user.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); break; } case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): From bd1745a50d7bb24060b45f57a7604c6cd17066d7 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 11 Sep 2025 07:37:45 +1000 Subject: [PATCH 62/85] =?UTF-8?q?[PM-24192]=20Add=20OrganizationContext=20?= =?UTF-8?q?in=20API=C2=A0project=20(#6291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...uthorizationHandlerCollectionExtensions.cs | 21 +++ .../Authorization/HttpContextExtensions.cs | 4 +- .../OrganizationClaimsExtensions.cs | 4 +- .../Authorization/OrganizationContext.cs | 84 ++++++++++++ .../Utilities/ServiceCollectionExtensions.cs | 8 +- .../Context/CurrentContextOrganization.cs | 4 + .../Authorization/OrganizationContextTests.cs | 125 ++++++++++++++++++ 7 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs create mode 100644 src/Api/AdminConsole/Authorization/OrganizationContext.cs create mode 100644 test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs new file mode 100644 index 0000000000..70cbc0d1a4 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Bit.Api.Vault.AuthorizationHandlers.Collections; +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Api.AdminConsole.Authorization; + +public static class AuthorizationHandlerCollectionExtensions +{ + public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection services) + { + services.TryAddScoped(); + + services.TryAddEnumerable([ + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); + } +} diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs index accb9539fa..5cb261b41d 100644 --- a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs +++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs index a3af3669ac..9ea01bd21b 100644 --- a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs +++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Security.Claims; +using System.Security.Claims; using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Authorization/OrganizationContext.cs b/src/Api/AdminConsole/Authorization/OrganizationContext.cs new file mode 100644 index 0000000000..7b06e33dfd --- /dev/null +++ b/src/Api/AdminConsole/Authorization/OrganizationContext.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Services; + +// Note: do not move this into Core! See remarks below. +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// Provides information about a user's membership or provider relationship with an organization. +/// Used for authorization decisions in the API layer, usually called by a controller or authorization handler or attribute. +/// +/// +/// This is intended to deprecate organization-related methods in . +/// It should remain in the API layer (not Core) because it is closely tied to user claims and authentication. +/// +public interface IOrganizationContext +{ + /// + /// Parses the provided for claims relating to the specified organization. + /// A user will have organization claims if they are a confirmed member of the organization. + /// + /// The claims for the user. + /// The organization to extract claims for. + /// + /// A representing the user's claims for the organization, + /// or null if the user has no claims. + /// + public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId); + /// + /// Used to determine whether the user is a ProviderUser for the specified organization. + /// + /// The claims for the user. + /// The organization to check the provider relationship for. + /// True if the user is a ProviderUser for the specified organization, otherwise false. + /// + /// This requires a database call, but the results are cached for the lifetime of the service instance. + /// Try to check purely claims-based sources of authorization first (such as organization membership with + /// ) to avoid unnecessary database calls. + /// + public Task IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId); +} + +public class OrganizationContext( + IUserService userService, + IProviderUserRepository providerUserRepository) : IOrganizationContext +{ + public const string NoUserIdError = "This method should only be called on the private api with a logged in user."; + + /// + /// Caches provider relationships by UserId. + /// In practice this should only have 1 entry (for the current user), but this approach ensures that a mix-up + /// between users cannot occur if is called with a different + /// ClaimsPrincipal for any reason. + /// + private readonly Dictionary> _providerUserOrganizationsCache = new(); + + public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId) + { + return user.GetCurrentContextOrganization(organizationId); + } + + public async Task IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId) + { + var userId = userService.GetProperUserId(user); + if (!userId.HasValue) + { + throw new InvalidOperationException(NoUserIdError); + } + + if (!_providerUserOrganizationsCache.TryGetValue(userId.Value, out var providerUserOrganizations)) + { + providerUserOrganizations = + await providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId.Value, + ProviderUserStatusType.Confirmed); + providerUserOrganizations = providerUserOrganizations.ToList(); + _providerUserOrganizationsCache[userId.Value] = providerUserOrganizations; + } + + return providerUserOrganizations.Any(o => o.OrganizationId == organizationId); + } +} diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index b956fc73bb..6af688f548 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,7 +1,5 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; @@ -109,14 +107,12 @@ public static class ServiceCollectionExtensions public static void AddAuthorizationHandlers(this IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + // Admin Console authorization handlers + services.AddAdminConsoleAuthorizationHandlers(); } public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/src/Core/AdminConsole/Context/CurrentContextOrganization.cs b/src/Core/AdminConsole/Context/CurrentContextOrganization.cs index 3c9dc10cc0..e154a5a25f 100644 --- a/src/Core/AdminConsole/Context/CurrentContextOrganization.cs +++ b/src/Core/AdminConsole/Context/CurrentContextOrganization.cs @@ -5,6 +5,10 @@ using Bit.Core.Utilities; namespace Bit.Core.Context; +/// +/// Represents the claims for a user in relation to a particular organization. +/// These claims will only be present for users in the status. +/// public class CurrentContextOrganization { public CurrentContextOrganization() { } diff --git a/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs b/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs new file mode 100644 index 0000000000..92109cea93 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs @@ -0,0 +1,125 @@ +using System.Security.Claims; +using AutoFixture; +using Bit.Api.AdminConsole.Authorization; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +[SutProviderCustomize] +public class OrganizationContextTests +{ + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UserIsProviderUser_ReturnsTrue( + Guid userId, Guid organizationId, Guid otherOrganizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + var providerUserOrganizations = new List + { + new() { OrganizationId = organizationId }, + new() { OrganizationId = otherOrganizationId } + }; + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + Assert.True(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed); + } + + public static IEnumerable UserIsNotProviderUserData() + { + // User has provider organizations, but not for the target organization + yield return + [ + new List + { + new Fixture().Create() + } + ]; + + // User has no provider organizations + yield return [Array.Empty()]; + } + + [Theory, BitMemberAutoData(nameof(UserIsNotProviderUserData))] + public async Task IsProviderUserForOrganization_UserIsNotProviderUser_ReturnsFalse( + IEnumerable providerUserOrganizations, + Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UserIdIsNull_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns((Guid?)null); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId)); + + Assert.Equal(OrganizationContext.NoUserIdError, exception.Message); + } + + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UsesCaching( + Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + var providerUserOrganizations = new List + { + new() { OrganizationId = organizationId } + }; + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + await sutProvider.GetDependency() + .Received(1) + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed); + } +} From 2c860df34bc245a621efb8e7799eef4f68b341ce Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:58:32 +1000 Subject: [PATCH 63/85] [PM-15621] Refactor delete claimed user command (#6221) - create vNext command - restructure command to simplify logic - move validation to a separate class - implement result types using OneOf library and demo their use here --- .../OrganizationUsersController.cs | 52 ++ .../OrganizationUserResponseModel.cs | 4 +- .../CommandResult.cs | 42 ++ ...imedOrganizationUserAccountCommandvNext.cs | 137 +++++ ...ClaimedOrganizationUserAccountValidator.cs | 76 +++ .../DeleteUserValidationRequest.cs | 13 + .../DeleteClaimedAccountvNext/Errors.cs | 21 + ...imedOrganizationUserAccountCommandvNext.cs | 17 + ...edOrganizationUserAccountValidatorvNext.cs | 6 + .../ValidationResult.cs | 41 ++ src/Core/Constants.cs | 1 + ...OrganizationServiceCollectionExtensions.cs | 5 + .../OrganizationUserControllerTests.cs | 121 ++++- .../OrganizationUsersControllerTests.cs | 21 - ...rganizationUserAccountCommandvNextTests.cs | 467 ++++++++++++++++ ...anizationUserAccountValidatorvNextTests.cs | 503 ++++++++++++++++++ 16 files changed, 1502 insertions(+), 25 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5183b59b00..16d6984334 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -11,6 +11,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -23,6 +24,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; @@ -59,6 +61,7 @@ public class OrganizationUsersController : Controller private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; + private readonly IDeleteClaimedOrganizationUserAccountCommandvNext _deleteClaimedOrganizationUserAccountCommandvNext; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; @@ -87,6 +90,7 @@ public class OrganizationUsersController : Controller IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, + IDeleteClaimedOrganizationUserAccountCommandvNext deleteClaimedOrganizationUserAccountCommandvNext, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, @@ -115,6 +119,7 @@ public class OrganizationUsersController : Controller _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; + _deleteClaimedOrganizationUserAccountCommandvNext = deleteClaimedOrganizationUserAccountCommandvNext; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; @@ -536,6 +541,12 @@ public class OrganizationUsersController : Controller [Authorize] public async Task DeleteAccount(Guid orgId, Guid id) { + if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) + { + await DeleteAccountvNext(orgId, id); + return; + } + var currentUser = await _userService.GetUserByPrincipalAsync(User); if (currentUser == null) { @@ -553,10 +564,33 @@ public class OrganizationUsersController : Controller await DeleteAccount(orgId, id); } + private async Task DeleteAccountvNext(Guid orgId, Guid id) + { + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + return TypedResults.Unauthorized(); + } + + var commandResult = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteUserAsync(orgId, id, currentUserId.Value); + + return commandResult.Result.Match( + error => error is NotFoundError + ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) + : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), + TypedResults.Ok + ); + } + [HttpDelete("delete-account")] [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { + if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) + { + return await BulkDeleteAccountvNext(orgId, model); + } + var currentUser = await _userService.GetUserByPrincipalAsync(User); if (currentUser == null) { @@ -577,6 +611,24 @@ public class OrganizationUsersController : Controller return await BulkDeleteAccount(orgId, model); } + private async Task> BulkDeleteAccountvNext(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value); + + var responses = result.Select(r => r.Result.Match( + error => new OrganizationUserBulkResponseModel(r.Id, error.Message), + _ => new OrganizationUserBulkResponseModel(r.Id, string.Empty) + )); + + return new ListResponseModel(responses); + } + [HttpPut("{id}/revoke")] [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 7c31c2ae81..eb810599f3 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -236,8 +236,8 @@ public class OrganizationUserPublicKeyResponseModel : ResponseModel public class OrganizationUserBulkResponseModel : ResponseModel { - public OrganizationUserBulkResponseModel(Guid id, string error, - string obj = "OrganizationBulkConfirmResponseModel") : base(obj) + public OrganizationUserBulkResponseModel(Guid id, string error) + : base("OrganizationBulkConfirmResponseModel") { Id = id; Error = error; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs new file mode 100644 index 0000000000..3dfbe4dbda --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs @@ -0,0 +1,42 @@ +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +/// +/// Represents the result of a command. +/// This is a type that contains an Error if the command execution failed, or the result of the command if it succeeded. +/// +/// The type of the successful result. If there is no successful result (void), use . + +public class CommandResult(OneOf result) : OneOfBase(result) +{ + public bool IsError => IsT0; + public bool IsSuccess => IsT1; + public Error AsError => AsT0; + public T AsSuccess => AsT1; + + public static implicit operator CommandResult(T value) => new(value); + public static implicit operator CommandResult(Error error) => new(error); +} + +/// +/// Represents the result of a command where successful execution returns no value (void). +/// See for more information. +/// +public class CommandResult(OneOf result) : CommandResult(result) +{ + public static implicit operator CommandResult(None none) => new(none); + public static implicit operator CommandResult(Error error) => new(error); +} + +/// +/// A wrapper for with an ID, to identify the result in bulk operations. +/// +public record BulkCommandResult(Guid Id, CommandResult Result); + +/// +/// A wrapper for with an ID, to identify the result in bulk operations. +/// +public record BulkCommandResult(Guid Id, CommandResult Result); + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs new file mode 100644 index 0000000000..3064a426fa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs @@ -0,0 +1,137 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public class DeleteClaimedOrganizationUserAccountCommandvNext( + IUserService userService, + IEventService eventService, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + IPushNotificationService pushService, + ILogger logger, + IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext) + : IDeleteClaimedOrganizationUserAccountCommandvNext +{ + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) + { + var result = await DeleteManyUsersAsync(organizationId, [organizationUserId], deletingUserId); + return result.Single(); + } + + public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid deletingUserId) + { + orgUserIds = orgUserIds.ToList(); + var orgUsers = await organizationUserRepository.GetManyAsync(orgUserIds); + var users = await GetUsersAsync(orgUsers); + var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); + + var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses); + var validationResults = (await deleteClaimedOrganizationUserAccountValidatorvNext.ValidateAsync(internalRequests)).ToList(); + + var validRequests = validationResults.ValidRequests(); + await CancelPremiumsAsync(validRequests); + await HandleUserDeletionsAsync(validRequests); + await LogDeletedOrganizationUsersAsync(validRequests); + + return validationResults.Select(v => v.Match( + error => new BulkCommandResult(v.Request.OrganizationUserId, error), + _ => new BulkCommandResult(v.Request.OrganizationUserId, new None()) + )); + } + + private static IEnumerable CreateInternalRequests( + Guid organizationId, + Guid deletingUserId, + IEnumerable orgUserIds, + ICollection orgUsers, + IEnumerable users, + IDictionary claimedStatuses) + { + foreach (var orgUserId in orgUserIds) + { + var orgUser = orgUsers.FirstOrDefault(orgUser => orgUser.Id == orgUserId); + var user = users.FirstOrDefault(user => user.Id == orgUser?.UserId); + claimedStatuses.TryGetValue(orgUserId, out var isClaimed); + + yield return new DeleteUserValidationRequest + { + User = user, + OrganizationUserId = orgUserId, + OrganizationUser = orgUser, + IsClaimed = isClaimed, + OrganizationId = organizationId, + DeletingUserId = deletingUserId, + }; + } + } + + private async Task> GetUsersAsync(ICollection orgUsers) + { + var userIds = orgUsers + .Where(orgUser => orgUser.UserId.HasValue) + .Select(orgUser => orgUser.UserId!.Value) + .ToList(); + + return await userRepository.GetManyAsync(userIds); + } + + private async Task LogDeletedOrganizationUsersAsync(IEnumerable requests) + { + var eventDate = DateTime.UtcNow; + + var events = requests + .Select(request => (request.OrganizationUser!, EventType.OrganizationUser_Deleted, (DateTime?)eventDate)) + .ToList(); + + if (events.Count != 0) + { + await eventService.LogOrganizationUserEventsAsync(events); + } + } + + private async Task HandleUserDeletionsAsync(IEnumerable requests) + { + var users = requests + .Select(request => request.User!) + .ToList(); + + if (users.Count == 0) + { + return; + } + + await userRepository.DeleteManyAsync(users); + + foreach (var user in users) + { + await pushService.PushLogOutAsync(user.Id); + } + } + + private async Task CancelPremiumsAsync(IEnumerable requests) + { + var users = requests.Select(request => request.User!); + + foreach (var user in users) + { + try + { + await userService.CancelPremiumAsync(user); + } + catch (GatewayException exception) + { + logger.LogWarning(exception, "Failed to cancel premium subscription for {userId}.", user.Id); + } + } + } +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs new file mode 100644 index 0000000000..7a88841d2f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs @@ -0,0 +1,76 @@ +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public class DeleteClaimedOrganizationUserAccountValidatorvNext( + ICurrentContext currentContext, + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidatorvNext +{ + public async Task>> ValidateAsync(IEnumerable requests) + { + var tasks = requests.Select(ValidateAsync); + var results = await Task.WhenAll(tasks); + return results; + } + + private async Task> ValidateAsync(DeleteUserValidationRequest request) + { + // Ensure user exists + if (request.User == null || request.OrganizationUser == null) + { + return Invalid(request, new UserNotFoundError()); + } + + // Cannot delete invited users + if (request.OrganizationUser.Status == OrganizationUserStatusType.Invited) + { + return Invalid(request, new InvalidUserStatusError()); + } + + // Cannot delete yourself + if (request.OrganizationUser.UserId == request.DeletingUserId) + { + return Invalid(request, new CannotDeleteYourselfError()); + } + + // Can only delete a claimed user + if (!request.IsClaimed) + { + return Invalid(request, new UserNotClaimedError()); + } + + // Cannot delete an owner unless you are an owner or provider + if (request.OrganizationUser.Type == OrganizationUserType.Owner && + !await currentContext.OrganizationOwner(request.OrganizationId)) + { + return Invalid(request, new CannotDeleteOwnersError()); + } + + // Cannot delete a user who is the sole owner of an organization + var onlyOwnerCount = await organizationUserRepository.GetCountByOnlyOwnerAsync(request.User.Id); + if (onlyOwnerCount > 0) + { + return Invalid(request, new SoleOwnerError()); + } + + // Cannot delete a user who is the sole member of a provider + var onlyOwnerProviderCount = await providerUserRepository.GetCountByOnlyOwnerAsync(request.User.Id); + if (onlyOwnerProviderCount > 0) + { + return Invalid(request, new SoleProviderError()); + } + + // Custom users cannot delete admins + if (request.OrganizationUser.Type == OrganizationUserType.Admin && await currentContext.OrganizationCustom(request.OrganizationId)) + { + return Invalid(request, new CannotDeleteAdminsError()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs new file mode 100644 index 0000000000..5fd95dc73c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public class DeleteUserValidationRequest +{ + public Guid OrganizationId { get; init; } + public Guid OrganizationUserId { get; init; } + public OrganizationUser? OrganizationUser { get; init; } + public User? User { get; init; } + public Guid DeletingUserId { get; init; } + public bool IsClaimed { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs new file mode 100644 index 0000000000..d991a882b8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs @@ -0,0 +1,21 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +/// +/// A strongly typed error containing a reason that an action failed. +/// This is used for business logic validation and other expected errors, not exceptions. +/// +public abstract record Error(string Message); +/// +/// An type that maps to a NotFoundResult at the api layer. +/// +/// +public abstract record NotFoundError(string Message) : Error(Message); + +public record UserNotFoundError() : NotFoundError("Invalid user."); +public record UserNotClaimedError() : Error("Member is not claimed by the organization."); +public record InvalidUserStatusError() : Error("You cannot delete a member with Invited status."); +public record CannotDeleteYourselfError() : Error("You cannot delete yourself."); +public record CannotDeleteOwnersError() : Error("Only owners can delete other owners."); +public record SoleOwnerError() : Error("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); +public record SoleProviderError() : Error("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); +public record CannotDeleteAdminsError() : Error("Custom users can not delete admins."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs new file mode 100644 index 0000000000..2c462a2acf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public interface IDeleteClaimedOrganizationUserAccountCommandvNext +{ + /// + /// Removes a user from an organization and deletes all of their associated user data. + /// + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId); + + /// + /// Removes multiple users from an organization and deletes all of their associated user data. + /// + /// + /// An error message for each user that could not be removed, otherwise null. + /// + Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid deletingUserId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs new file mode 100644 index 0000000000..f6125a0355 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public interface IDeleteClaimedOrganizationUserAccountValidatorvNext +{ + Task>> ValidateAsync(IEnumerable requests); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs new file mode 100644 index 0000000000..23d2fbb7ce --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs @@ -0,0 +1,41 @@ +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +/// +/// Represents the result of validating a request. +/// This is for use within the Core layer, e.g. validating a command request. +/// +/// The request that has been validated. +/// A type that contains an Error if validation failed. +/// The request type. +public class ValidationResult(TRequest request, OneOf error) : OneOfBase(error) +{ + public TRequest Request { get; } = request; + + public bool IsError => IsT0; + public bool IsValid => IsT1; + public Error AsError => AsT0; +} + +public static class ValidationResultHelpers +{ + /// + /// Creates a successful with no error set. + /// + public static ValidationResult Valid(T request) => new(request, new None()); + /// + /// Creates a failed with the specified error. + /// + public static ValidationResult Invalid(T request, Error error) => new(request, error); + + /// + /// Extracts successfully validated requests from a sequence of . + /// + public static List ValidRequests(this IEnumerable> results) => + results + .Where(r => r.IsValid) + .Select(r => r.Request) + .ToList(); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cba060427c..3a825bc533 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,6 +134,7 @@ public static class FeatureFlagKeys public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; + public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index bcbaccca7c..1c38a27d1e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -133,6 +134,10 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // vNext implementations (feature flagged) + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index 04ab72fad1..b7839467e8 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -1,10 +1,13 @@ using System.Net; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -30,6 +33,10 @@ public class OrganizationUserControllerTests : IClassFixture(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(orgUserToDelete.UserId); + Assert.NotNull(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.NotNull(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUserToDelete.Id] + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + Assert.Single(content.Data, r => r.Id == orgUserToDelete.Id && r.Error == string.Empty); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + } + + [Fact] + public async Task BulkDeleteAccount_MixedResults() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(userEmail); + + // Can delete users + var (_, validOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + // Cannot delete owners + var (_, invalidOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Owner); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(validOrgUser.UserId); + Assert.NotNull(invalidOrgUser.UserId); + + var arrangedUsers = + await userRepository.GetManyAsync([validOrgUser.UserId.Value, invalidOrgUser.UserId.Value]); + Assert.Equal(2, arrangedUsers.Count()); + + var arrangedOrgUsers = + await organizationUserRepository.GetManyAsync([validOrgUser.Id, invalidOrgUser.Id]); + Assert.Equal(2, arrangedOrgUsers.Count); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [validOrgUser.Id, invalidOrgUser.Id] + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + var debug = await httpResponse.Content.ReadAsStringAsync(); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + Assert.Equal(2, content.Data.Count()); + Assert.Contains(content.Data, r => r.Id == validOrgUser.Id && r.Error == string.Empty); + Assert.Contains(content.Data, r => + r.Id == invalidOrgUser.Id && + string.Equals(r.Error, new CannotDeleteOwnersError().Message, StringComparison.Ordinal)); + + var actualUsers = + await userRepository.GetManyAsync([validOrgUser.UserId.Value, invalidOrgUser.UserId.Value]); + Assert.Single(actualUsers, u => u.Id == invalidOrgUser.UserId.Value); + + var actualOrgUsers = + await organizationUserRepository.GetManyAsync([validOrgUser.Id, invalidOrgUser.Id]); + Assert.Single(actualOrgUsers, ou => ou.Id == invalidOrgUser.Id); + } + [Theory] [InlineData(OrganizationUserType.User)] [InlineData(OrganizationUserType.Custom)] @@ -57,11 +149,36 @@ public class OrganizationUserControllerTests : IClassFixture { Guid.NewGuid() } }; - var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/remove", request); + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); } + [Fact] + public async Task DeleteAccount_Success() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(userEmail); + + var (_, orgUserToDelete) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(orgUserToDelete.UserId); + Assert.NotNull(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.NotNull(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + + var httpResponse = await _client.DeleteAsync($"organizations/{_organization.Id}/users/{orgUserToDelete.Id}/delete-account"); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + } + [Theory] [InlineData(OrganizationUserType.User)] [InlineData(OrganizationUserType.Custom)] @@ -74,7 +191,7 @@ public class OrganizationUserControllerTests : IClassFixture deleteResults, SutProvider sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - sutProvider.GetDependency() - .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id) - .Returns(deleteResults); - - var response = await sutProvider.Sut.BulkDeleteAccount(orgId, model); - - Assert.Equal(deleteResults.Count, response.Data.Count()); - Assert.True(response.Data.All(r => deleteResults.Any(res => res.Item1 == r.Id && res.Item2 == r.Error))); - await sutProvider.GetDependency() - .Received(1) - .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); - } - [Theory] [BitAutoData] public async Task BulkDeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs new file mode 100644 index 0000000000..679c1914c6 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs @@ -0,0 +1,467 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +[SutProviderCustomize] +public class DeleteClaimedOrganizationUserAccountCommandvNextTests +{ + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_WithValidSingleUser_CallsDeleteManyUsersAsync( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + organizationUser.OrganizationId = organizationId; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var validationResult = CreateSuccessfulValidationResult(request); + + SetupRepositoryMocks(sutProvider, + new List { organizationUser }, + [user], + organizationId, + new Dictionary { { organizationUser.Id, true } }); + + SetupValidatorMock(sutProvider, [validationResult]); + + var result = await sutProvider.Sut.DeleteUserAsync(organizationId, organizationUser.Id, deletingUserId); + + Assert.Equal(organizationUser.Id, result.Id); + Assert.True(result.Result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1) + .GetManyAsync(Arg.Is>(ids => ids.Contains(organizationUser.Id))); + + await AssertSuccessfulUserOperations(sutProvider, [user], [organizationUser]); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithEmptyUserIds_ReturnsEmptyResults( + SutProvider sutProvider, + Guid organizationId, + Guid deletingUserId) + { + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [], deletingUserId); + + Assert.Empty(results); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( + SutProvider sutProvider, + User user1, + User user2, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser1, + [OrganizationUser] OrganizationUser orgUser2) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + + var request1 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser1.Id, + OrganizationUser = orgUser1, + User = user1, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var request2 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser2.Id, + OrganizationUser = orgUser2, + User = user2, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var validationResults = new[] + { + CreateSuccessfulValidationResult(request1), + CreateSuccessfulValidationResult(request2) + }; + + SetupRepositoryMocks(sutProvider, + new List { orgUser1, orgUser2 }, + [user1, user2], + organizationId, + new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); + + SetupValidatorMock(sutProvider, validationResults); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser1.Id, orgUser2.Id], deletingUserId); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + Assert.All(resultsList, result => Assert.True(result.Result.IsSuccess)); + + await AssertSuccessfulUserOperations(sutProvider, [user1, user2], [orgUser1, orgUser2]); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithValidationErrors_ReturnsErrorResults( + SutProvider sutProvider, + Guid organizationId, + Guid orgUserId1, + Guid orgUserId2, + Guid deletingUserId) + { + // Arrange + var request1 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUserId1, + DeletingUserId = deletingUserId + }; + var request2 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUserId2, + DeletingUserId = deletingUserId + }; + + var validationResults = new[] + { + CreateFailedValidationResult(request1, new UserNotClaimedError()), + CreateFailedValidationResult(request2, new InvalidUserStatusError()) + }; + + SetupRepositoryMocks(sutProvider, [], [], organizationId, new Dictionary()); + SetupValidatorMock(sutProvider, validationResults); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserId1, orgUserId2], deletingUserId); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + Assert.Equal(orgUserId1, resultsList[0].Id); + Assert.True(resultsList[0].Result.IsError); + Assert.IsType(resultsList[0].Result.AsError); + + Assert.Equal(orgUserId2, resultsList[1].Id); + Assert.True(resultsList[1].Result.IsError); + Assert.IsType(resultsList[1].Result.AsError); + + await AssertNoUserOperations(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartialSuccessCorrectly( + SutProvider sutProvider, + User validUser, + Guid organizationId, + Guid validOrgUserId, + Guid invalidOrgUserId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser validOrgUser) + { + validOrgUser.Id = validOrgUserId; + validOrgUser.UserId = validUser.Id; + validOrgUser.OrganizationId = organizationId; + + var validRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = validOrgUserId, + OrganizationUser = validOrgUser, + User = validUser, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var invalidRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = invalidOrgUserId, + DeletingUserId = deletingUserId + }; + + var validationResults = new[] + { + CreateSuccessfulValidationResult(validRequest), + CreateFailedValidationResult(invalidRequest, new UserNotFoundError()) + }; + + SetupRepositoryMocks(sutProvider, + new List { validOrgUser }, + [validUser], + organizationId, + new Dictionary { { validOrgUserId, true } }); + + SetupValidatorMock(sutProvider, validationResults); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [validOrgUserId, invalidOrgUserId], deletingUserId); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var validResult = resultsList.First(r => r.Id == validOrgUserId); + var invalidResult = resultsList.First(r => r.Id == invalidOrgUserId); + + Assert.True(validResult.Result.IsSuccess); + Assert.True(invalidResult.Result.IsError); + Assert.IsType(invalidResult.Result.AsError); + + await AssertSuccessfulUserOperations(sutProvider, [validUser], [validOrgUser]); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayExceptionAndLogsWarning( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser) + { + orgUser.UserId = user.Id; + orgUser.OrganizationId = organizationId; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser.Id, + OrganizationUser = orgUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var validationResult = CreateSuccessfulValidationResult(request); + + SetupRepositoryMocks(sutProvider, + new List { orgUser }, + [user], + organizationId, + new Dictionary { { orgUser.Id, true } }); + + SetupValidatorMock(sutProvider, [validationResult]); + + var gatewayException = new GatewayException("Payment gateway error"); + sutProvider.GetDependency() + .CancelPremiumAsync(user) + .ThrowsAsync(gatewayException); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser.Id], deletingUserId); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList.First().Result.IsSuccess); + + await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); + await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains($"Failed to cancel premium subscription for {user.Id}")), + gatewayException, + Arg.Any>()); + } + + + [Theory] + [BitAutoData] + public async Task CreateInternalRequests_CreatesCorrectRequestsForAllUsers( + SutProvider sutProvider, + User user1, + User user2, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser1, + [OrganizationUser] OrganizationUser orgUser2) + { + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + var orgUserIds = new[] { orgUser1.Id, orgUser2.Id }; + var orgUsers = new List { orgUser1, orgUser2 }; + var users = new[] { user1, user2 }; + var claimedStatuses = new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, false } }; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) + .Returns(users); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) + .Returns(claimedStatuses); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any>()) + .Returns(callInfo => + { + var requests = callInfo.Arg>(); + return requests.Select(r => CreateFailedValidationResult(r, new UserNotFoundError())); + }); + + // Act + await sutProvider.Sut.DeleteManyUsersAsync(organizationId, orgUserIds, deletingUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ValidateAsync(Arg.Is>(requests => + requests.Count() == 2 && + requests.Any(r => r.OrganizationUserId == orgUser1.Id && + r.OrganizationId == organizationId && + r.OrganizationUser == orgUser1 && + r.User == user1 && + r.DeletingUserId == deletingUserId && + r.IsClaimed == true) && + requests.Any(r => r.OrganizationUserId == orgUser2.Id && + r.OrganizationId == organizationId && + r.OrganizationUser == orgUser2 && + r.User == user2 && + r.DeletingUserId == deletingUserId && + r.IsClaimed == false))); + } + + [Theory] + [BitAutoData] + public async Task GetUsersAsync_WithNullUserIds_ReturnsEmptyCollection( + SutProvider sutProvider, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUserWithoutUserId) + { + orgUserWithoutUserId.UserId = null; // Intentionally setting to null for test case + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUserWithoutUserId }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => !ids.Any())) + .Returns([]); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any>()) + .Returns(callInfo => + { + var requests = callInfo.Arg>(); + return requests.Select(r => CreateFailedValidationResult(r, new UserNotFoundError())); + }); + + // Act + await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserWithoutUserId.Id], deletingUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ValidateAsync(Arg.Is>(requests => + requests.Count() == 1 && + requests.Single().User == null)); + + await sutProvider.GetDependency().Received(1) + .GetManyAsync(Arg.Is>(ids => !ids.Any())); + } + + private static ValidationResult CreateSuccessfulValidationResult( + DeleteUserValidationRequest request) => + ValidationResultHelpers.Valid(request); + + private static ValidationResult CreateFailedValidationResult( + DeleteUserValidationRequest request, + Error error) => + ValidationResultHelpers.Invalid(request, error); + + private static void SetupRepositoryMocks( + SutProvider sutProvider, + ICollection orgUsers, + IEnumerable users, + Guid organizationId, + Dictionary claimedStatuses) + { + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(users); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) + .Returns(claimedStatuses); + } + + private static void SetupValidatorMock( + SutProvider sutProvider, + IEnumerable> validationResults) + { + sutProvider.GetDependency() + .ValidateAsync(Arg.Any>()) + .Returns(validationResults); + } + + private static async Task AssertSuccessfulUserOperations( + SutProvider sutProvider, + IEnumerable expectedUsers, + IEnumerable expectedOrgUsers) + { + var userList = expectedUsers.ToList(); + var orgUserList = expectedOrgUsers.ToList(); + + await sutProvider.GetDependency().Received(1) + .DeleteManyAsync(Arg.Is>(users => + userList.All(expectedUser => users.Any(u => u.Id == expectedUser.Id)))); + + foreach (var user in userList) + { + await sutProvider.GetDependency().Received(1).PushLogOutAsync(user.Id); + } + + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => + orgUserList.All(expectedOrgUser => + events.Any(e => e.Item1.Id == expectedOrgUser.Id && e.Item2 == EventType.OrganizationUser_Deleted)))); + } + + private static async Task AssertNoUserOperations(SutProvider sutProvider) + { + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteManyAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushLogOutAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventsAsync(default(IEnumerable<(OrganizationUser, EventType, DateTime?)>)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs new file mode 100644 index 0000000000..e51df6a626 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs @@ -0,0 +1,503 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +[SutProviderCustomize] +public class DeleteClaimedOrganizationUserAccountValidatorvNextTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithValidSingleRequest_ReturnsValidResult( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + organizationUser.OrganizationId = organizationId; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsValid); + Assert.Equal(request, resultsList[0].Request); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMultipleValidRequests_ReturnsAllValidResults( + SutProvider sutProvider, + User user1, + User user2, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2) + { + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = organizationId; + + orgUser2.UserId = user2.Id; + orgUser2.OrganizationId = organizationId; + + var request1 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser1.Id, + OrganizationUser = orgUser1, + User = user1, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var request2 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser2.Id, + OrganizationUser = orgUser2, + User = user2, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user1.Id); + SetupMocks(sutProvider, organizationId, user2.Id); + + var results = await sutProvider.Sut.ValidateAsync([request1, request2]); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + Assert.All(resultsList, result => Assert.True(result.IsValid)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithNullUser_ReturnsUserNotFoundError( + SutProvider sutProvider, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = null, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithNullOrganizationUser_ReturnsUserNotFoundError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId) + { + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = Guid.NewGuid(), + OrganizationUser = null, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithInvitedUser_ReturnsInvalidUserStatusError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenDeletingYourself_ReturnsCannotDeleteYourselfError( + SutProvider sutProvider, + User user, + Guid organizationId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = user.Id, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithUnclaimedUser_ReturnsUserNotClaimedError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = false + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsNotOwner_ReturnsCannotDeleteOwnersError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id, OrganizationUserType.Admin); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsOwner_ReturnsValidResult( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithSoleOwnerOfOrganization_ReturnsSoleOwnerError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(user.Id) + .Returns(1); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithSoleProviderOwner_ReturnsSoleProviderError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(user.Id) + .Returns(1); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_CustomUserDeletingAdmin_ReturnsCannotDeleteAdminsError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id, OrganizationUserType.Custom); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_AdminDeletingAdmin_ReturnsValidResult( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id, OrganizationUserType.Admin); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMixedValidAndInvalidRequests_ReturnsCorrespondingResults( + SutProvider sutProvider, + User validUser, + User invalidUser, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser validOrgUser, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser invalidOrgUser) + { + validOrgUser.UserId = validUser.Id; + + invalidOrgUser.UserId = invalidUser.Id; + + var validRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = validOrgUser.Id, + OrganizationUser = validOrgUser, + User = validUser, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var invalidRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = invalidOrgUser.Id, + OrganizationUser = invalidOrgUser, + User = invalidUser, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, validUser.Id); + + var results = await sutProvider.Sut.ValidateAsync([validRequest, invalidRequest]); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var validResult = resultsList.First(r => r.Request == validRequest); + var invalidResult = resultsList.First(r => r.Request == invalidRequest); + + Assert.True(validResult.IsValid); + Assert.True(invalidResult.IsError); + Assert.IsType(invalidResult.AsError); + } + + private static void SetupMocks( + SutProvider sutProvider, + Guid organizationId, + Guid userId, + OrganizationUserType currentUserType = OrganizationUserType.Owner) + { + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(currentUserType == OrganizationUserType.Owner); + + sutProvider.GetDependency() + .OrganizationAdmin(organizationId) + .Returns(currentUserType is OrganizationUserType.Owner or OrganizationUserType.Admin); + + sutProvider.GetDependency() + .OrganizationCustom(organizationId) + .Returns(currentUserType is OrganizationUserType.Custom); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(userId) + .Returns(0); + } +} From 51c9958ff1276483f08e8a67fd650d03b88da01e Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 11 Sep 2025 08:21:04 -0500 Subject: [PATCH 64/85] update global settings for icons service so URIs are available internally (#6303) --- src/Icons/appsettings.Development.json | 17 +++++++++++++++++ src/Icons/appsettings.Production.json | 17 +++++++++++++++++ src/Icons/appsettings.QA.json | 17 +++++++++++++++++ src/Icons/appsettings.SelfHosted.json | 19 +++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 src/Icons/appsettings.SelfHosted.json diff --git a/src/Icons/appsettings.Development.json b/src/Icons/appsettings.Development.json index fa8ce71a97..b7d7186ffa 100644 --- a/src/Icons/appsettings.Development.json +++ b/src/Icons/appsettings.Development.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://localhost:8080", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "admin": "http://localhost:62911", + "notifications": "http://localhost:61840", + "sso": "http://localhost:51822", + "internalNotifications": "http://localhost:61840", + "internalAdmin": "http://localhost:62911", + "internalIdentity": "http://localhost:33656", + "internalApi": "http://localhost:4000", + "internalVault": "https://localhost:8080", + "internalSso": "http://localhost:51822", + "internalScim": "http://localhost:44559" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.Production.json b/src/Icons/appsettings.Production.json index 437045a7fb..828e8c61cc 100644 --- a/src/Icons/appsettings.Production.json +++ b/src/Icons/appsettings.Production.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.bitwarden.com", + "api": "https://api.bitwarden.com", + "identity": "https://identity.bitwarden.com", + "admin": "https://admin.bitwarden.com", + "notifications": "https://notifications.bitwarden.com", + "sso": "https://sso.bitwarden.com", + "internalNotifications": "https://notifications.bitwarden.com", + "internalAdmin": "https://admin.bitwarden.com", + "internalIdentity": "https://identity.bitwarden.com", + "internalApi": "https://api.bitwarden.com", + "internalVault": "https://vault.bitwarden.com", + "internalSso": "https://sso.bitwarden.com", + "internalScim": "https://scim.bitwarden.com" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.QA.json b/src/Icons/appsettings.QA.json index aec6c424af..ad323c8af6 100644 --- a/src/Icons/appsettings.QA.json +++ b/src/Icons/appsettings.QA.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.qa.bitwarden.pw", + "api": "https://api.qa.bitwarden.pw", + "identity": "https://identity.qa.bitwarden.pw", + "admin": "https://admin.qa.bitwarden.pw", + "notifications": "https://notifications.qa.bitwarden.pw", + "sso": "https://sso.qa.bitwarden.pw", + "internalNotifications": "https://notifications.qa.bitwarden.pw", + "internalAdmin": "https://admin.qa.bitwarden.pw", + "internalIdentity": "https://identity.qa.bitwarden.pw", + "internalApi": "https://api.qa.bitwarden.pw", + "internalVault": "https://vault.qa.bitwarden.pw", + "internalSso": "https://sso.qa.bitwarden.pw", + "internalScim": "https://scim.qa.bitwarden.pw" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.SelfHosted.json b/src/Icons/appsettings.SelfHosted.json new file mode 100644 index 0000000000..37faf24b59 --- /dev/null +++ b/src/Icons/appsettings.SelfHosted.json @@ -0,0 +1,19 @@ +{ + "globalSettings": { + "baseServiceUri": { + "vault": null, + "api": null, + "identity": null, + "admin": null, + "notifications": null, + "sso": null, + "internalNotifications": null, + "internalAdmin": null, + "internalIdentity": null, + "internalApi": null, + "internalVault": null, + "internalSso": null, + "internalScim": null + } + } +} From aab50ef5c4753b0a25d43202d45afadd8fa1a5c9 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 11 Sep 2025 08:25:57 -0500 Subject: [PATCH 65/85] [PM-24595] [PM-24596] Remove feature flag usage/definition for deleting users with no mp on import (#6313) * chore: remove dc prevent non-mp users from being deleted feature flag, refs PM-24596 * chore: format, refs PM-24596 --- .../Import/ImportOrganizationUsersAndGroupsCommand.cs | 8 ++------ src/Core/Constants.cs | 1 - .../ImportOrganizationUsersAndGroupsCommandTests.cs | 9 --------- .../ImportOrganizationUsersAndGroupsCommandTests.cs | 2 -- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs index 87c6ddea6f..a78dd95260 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -22,7 +22,6 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; private readonly IOrganizationService _organizationService; - private readonly IFeatureService _featureService; private readonly EventSystemUser _EventSystemUser = EventSystemUser.PublicApi; @@ -31,8 +30,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA IPaymentService paymentService, IGroupRepository groupRepository, IEventService eventService, - IOrganizationService organizationService, - IFeatureService featureService) + IOrganizationService organizationService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -40,7 +38,6 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA _groupRepository = groupRepository; _eventService = eventService; _organizationService = organizationService; - _featureService = featureService; } /// @@ -238,8 +235,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId)) .ToList(); - if (_featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) && - usersToDelete.Any(u => !u.HasMasterPassword)) + if (usersToDelete.Any(u => !u.HasMasterPassword)) { // Removing users without an MP will put their account in an unrecoverable state. // We allow this during normal syncs for offboarding, but overwriteExisting risks bricking every user in diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a825bc533..bef947b2b7 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,7 +131,6 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; - public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; diff --git a/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs index 2aea7ac4cd..32c7f75a2b 100644 --- a/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -2,14 +2,11 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Repositories; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Import; @@ -25,12 +22,6 @@ public class ImportOrganizationUsersAndGroupsCommandTests : IClassFixture - { - featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs index bff1af1cde..933bcbc3a1 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -98,8 +98,6 @@ public class ImportOrganizationUsersAndGroupsCommandTests SetupOrganizationConfigForImport(sutProvider, org, existingUsers, []); // Existing user does not have a master password - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) - .Returns(true); existingUsers.First().HasMasterPassword = false; sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); From c2cf29005406e149118b9b14740a67c1add6925e Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:04:05 +0100 Subject: [PATCH 66/85] [PM-21938] Fix: Invoice Payment Issues After Payment Method Updates (#6306) * Resolve the unpaid issue after valid payment method is added * Removed the draft status * Remove draft from the logger msg --- .../Implementations/SubscriberService.cs | 45 +++---------------- .../Services/SubscriberServiceTests.cs | 20 ++------- 2 files changed, 11 insertions(+), 54 deletions(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 1206397d9e..8e75bf3dca 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -580,11 +580,6 @@ public class SubscriberService( PaymentMethod = token }); - var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions - { - Customer = subscriber.GatewayCustomerId - }); - // Find the setup intent for the incoming payment method token. var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod; @@ -597,24 +592,15 @@ public class SubscriberService( var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); - // Find the customer's existing setup intents that should be canceled. - var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) - .Where(si => - si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); - // Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later. await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id); - // Cancel the customer's other open setup intents. - var postProcessing = existingSetupIntentsForCustomer.Select(si => - stripeAdapter.SetupIntentCancel(si.Id, - new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); - // Remove the customer's other attached Stripe payment methods. - postProcessing.Add(RemoveStripePaymentMethodsAsync(customer)); - - // Remove the customer's Braintree customer ID. - postProcessing.Add(RemoveBraintreeCustomerIdAsync(customer)); + var postProcessing = new List + { + RemoveStripePaymentMethodsAsync(customer), + RemoveBraintreeCustomerIdAsync(customer) + }; await Task.WhenAll(postProcessing); @@ -622,11 +608,6 @@ public class SubscriberService( } case PaymentMethodType.Card: { - var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions - { - Customer = subscriber.GatewayCustomerId - }); - // Remove the customer's other attached Stripe payment methods. await RemoveStripePaymentMethodsAsync(customer); @@ -634,16 +615,6 @@ public class SubscriberService( await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - // Find the customer's existing setup intents that should be canceled. - var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) - .Where(si => - si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); - - // Cancel the customer's other open setup intents. - var postProcessing = existingSetupIntentsForCustomer.Select(si => - stripeAdapter.SetupIntentCancel(si.Id, - new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); - var metadata = customer.Metadata; if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value)) @@ -653,16 +624,14 @@ public class SubscriberService( } // Set the customer's default payment method in Stripe and remove their Braintree customer ID. - postProcessing.Add(stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token }, Metadata = metadata - })); - - await Task.WhenAll(postProcessing); + }); break; } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index de8c6aae19..2569ffff00 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1320,12 +1320,6 @@ public class SubscriberServiceTests stripeAdapter.SetupIntentList(Arg.Is(options => options.PaymentMethod == "TOKEN")) .Returns([matchingSetupIntent]); - stripeAdapter.SetupIntentList(Arg.Is(options => options.Customer == provider.GatewayCustomerId)) - .Returns([ - new SetupIntent { Id = "setup_intent_2", Status = "requires_payment_method" }, - new SetupIntent { Id = "setup_intent_3", Status = "succeeded" } - ]); - stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ new PaymentMethod { Id = "payment_method_1" } ]); @@ -1335,8 +1329,8 @@ public class SubscriberServiceTests await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_1"); - await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_2", - Arg.Is(options => options.CancellationReason == "abandoned")); + await stripeAdapter.DidNotReceive().SetupIntentCancel(Arg.Any(), + Arg.Any()); await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); @@ -1364,12 +1358,6 @@ public class SubscriberServiceTests } }); - stripeAdapter.SetupIntentList(Arg.Is(options => options.Customer == provider.GatewayCustomerId)) - .Returns([ - new SetupIntent { Id = "setup_intent_2", Status = "requires_payment_method" }, - new SetupIntent { Id = "setup_intent_3", Status = "succeeded" } - ]); - stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ new PaymentMethod { Id = "payment_method_1" } ]); @@ -1377,8 +1365,8 @@ public class SubscriberServiceTests await sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.Card, "TOKEN")); - await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_2", - Arg.Is(options => options.CancellationReason == "abandoned")); + await stripeAdapter.DidNotReceive().SetupIntentCancel(Arg.Any(), + Arg.Any()); await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); From ba57ca5f6769d6e1edbd57702f1e2a96352f904a Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:04:37 -0600 Subject: [PATCH 67/85] BRE-1075: Migrate k6 loadtests to Datadog (#6293) * Remove external loadImpact option that is being replaced by DataDog * Add load test workflow Keep otel encrypted, but skip verification Go back to what was working from Billing-Relay Tune test configuration based on last test output. Tune config loadtest Tune tests a bit more by removing preAllocatedVUs Revert "Tune tests a bit more by removing preAllocatedVUs" This reverts commit ab1d170e7a3a6b4296f2c44ed741656a75979c80. Revert "Tune config loadtest" This reverts commit 5bbd551421658e8eb0e2651fb1e005c7f1d52c99. Tune config.js by reducing the amount of pAV Revert "Tune config.js by reducing the amount of pAV" This reverts commit 1e238d335c27ebf46992541ca3733178e165b3aa. Drop MaxVUs * Update .github/workflows/load-test.yml Co-authored-by: Matt Bishop * Fix newline at end of load-test.yml file * Fix github PR accepted code suggestion --------- Co-authored-by: Matt Bishop --- .github/workflows/load-test.yml | 113 ++++++++++++++++++++++++++++++-- perf/load/config.js | 6 -- perf/load/groups.js | 6 -- perf/load/login.js | 6 -- perf/load/sync.js | 6 -- 5 files changed, 106 insertions(+), 31 deletions(-) diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 19aab89be3..c582e6ba00 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -1,13 +1,112 @@ -name: Test Stub +name: Load test + on: + schedule: + - cron: "0 0 * * 1" # Run every Monday at 00:00 workflow_dispatch: + inputs: + test-id: + type: string + description: "Identifier label for Datadog metrics" + default: "server-load-test" + k6-test-path: + type: string + description: "Path to load test files" + default: "perf/load/*.js" + k6-flags: + type: string + description: "Additional k6 flags" + api-env-url: + type: string + description: "URL of the API environment" + default: "https://api.qa.bitwarden.pw" + identity-env-url: + type: string + description: "URL of the Identity environment" + default: "https://identity.qa.bitwarden.pw" + +permissions: + contents: read + id-token: write + +env: + # Secret configuration + AZURE_KEY_VAULT_NAME: gh-server + AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH + # Specify defaults for scheduled runs + TEST_ID: ${{ inputs.test-id || 'server-load-test' }} + K6_TEST_PATH: ${{ inputs.k6-test-path || 'test/load/*.js' }} + API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }} + IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }} jobs: - test: - permissions: - contents: read - name: Test + run-tests: + name: Run load tests runs-on: ubuntu-24.04 steps: - - name: Test - run: exit 0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: ${{ env.AZURE_KEY_VAULT_NAME }} + secrets: ${{ env.AZURE_KEY_VAULT_SECRETS }} + + - name: Log out of Azure + uses: bitwarden/gh-actions/azure-logout@main + + # Datadog agent for collecting OTEL metrics from k6 + - name: Start Datadog agent + run: | + docker run --detach \ + --name datadog-agent \ + -p 4317:4317 \ + -p 5555:5555 \ + -e DD_SITE=us3.datadoghq.com \ + -e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \ + -e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \ + -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ + -e DD_HEALTH_PORT=5555 \ + -e HOST_PROC=/proc \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --volume /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \ + --health-cmd "curl -f http://localhost:5555/health || exit 1" \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 10 \ + --health-start-period 30s \ + --pid host \ + datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479 + + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up k6 + uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0 + + - name: Run k6 tests + uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0 + continue-on-error: false + env: + K6_OTEL_METRIC_PREFIX: k6_ + K6_OTEL_GRPC_EXPORTER_INSECURE: true + # Load test specific environment variables + API_URL: ${{ env.API_ENV_URL }} + IDENTITY_URL: ${{ env.IDENTITY_ENV_URL }} + CLIENT_ID: ${{ steps.get-kv-secrets.outputs.K6-CLIENT-ID }} + AUTH_USER_EMAIL: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-EMAIL }} + AUTH_USER_PASSWORD_HASH: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-PASSWORD-HASH }} + with: + flags: >- + --tag test-id=${{ env.TEST_ID }} + -o experimental-opentelemetry + ${{ inputs.k6-flags }} + path: ${{ env.K6_TEST_PATH }} diff --git a/perf/load/config.js b/perf/load/config.js index f4e1b33bc0..ab7bb8d2fa 100644 --- a/perf/load/config.js +++ b/perf/load/config.js @@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Config", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/groups.js b/perf/load/groups.js index aee3b3e94d..71e8decdcb 100644 --- a/perf/load/groups.js +++ b/perf/load/groups.js @@ -10,12 +10,6 @@ const AUTH_CLIENT_ID = __ENV.AUTH_CLIENT_ID; const AUTH_CLIENT_SECRET = __ENV.AUTH_CLIENT_SECRET; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Groups", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/login.js b/perf/load/login.js index 096974f599..d45b86da5f 100644 --- a/perf/load/login.js +++ b/perf/load/login.js @@ -6,12 +6,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Login", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/sync.js b/perf/load/sync.js index 5624803e84..2eb2a54403 100644 --- a/perf/load/sync.js +++ b/perf/load/sync.js @@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Sync", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", From 7eb5035d94ed67927d3f638ebd34d89003507441 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:53:11 -0400 Subject: [PATCH 68/85] [PM-22740] Update current context to jive with Send Access Tokens (#6307) * feat: modify current context to not include user information * fix: circular dependency for feature check in current context. Successfully tested client isn't affected with feature flag off. * test: whole bunch of tests for current context --- src/Core/Context/CurrentContext.cs | 39 +- test/Core.Test/Context/CurrentContextTests.cs | 733 ++++++++++++++++++ 2 files changed, 754 insertions(+), 18 deletions(-) create mode 100644 test/Core.Test/Context/CurrentContextTests.cs diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index e824a30a0e..5d9b5a1759 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -18,10 +18,10 @@ using Microsoft.AspNetCore.Http; namespace Bit.Core.Context; -public class CurrentContext : ICurrentContext +public class CurrentContext( + IProviderOrganizationRepository _providerOrganizationRepository, + IProviderUserRepository _providerUserRepository) : ICurrentContext { - private readonly IProviderOrganizationRepository _providerOrganizationRepository; - private readonly IProviderUserRepository _providerUserRepository; private bool _builtHttpContext; private bool _builtClaimsPrincipal; private IEnumerable _providerOrganizationProviderDetails; @@ -48,14 +48,6 @@ public class CurrentContext : ICurrentContext public virtual IdentityClientType IdentityClientType { get; set; } public virtual Guid? ServiceAccountOrganizationId { get; set; } - public CurrentContext( - IProviderOrganizationRepository providerOrganizationRepository, - IProviderUserRepository providerUserRepository) - { - _providerOrganizationRepository = providerOrganizationRepository; - _providerUserRepository = providerUserRepository; - } - public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) { if (_builtHttpContext) @@ -137,6 +129,24 @@ public class CurrentContext : ICurrentContext var claimsDict = user.Claims.GroupBy(c => c.Type).ToDictionary(c => c.Key, c => c.Select(v => v)); + ClientId = GetClaimValue(claimsDict, "client_id"); + + var clientType = GetClaimValue(claimsDict, Claims.Type); + if (clientType != null) + { + if (Enum.TryParse(clientType, out IdentityClientType c)) + { + IdentityClientType = c; + } + } + + if (IdentityClientType == IdentityClientType.Send) + { + // For the Send client, we don't need to set any User specific properties on the context + // so just short circuit and return here. + return Task.FromResult(0); + } + var subject = GetClaimValue(claimsDict, "sub"); if (Guid.TryParse(subject, out var subIdGuid)) { @@ -165,13 +175,6 @@ public class CurrentContext : ICurrentContext } } - var clientType = GetClaimValue(claimsDict, Claims.Type); - if (clientType != null) - { - Enum.TryParse(clientType, out IdentityClientType c); - IdentityClientType = c; - } - if (IdentityClientType == IdentityClientType.ServiceAccount) { ServiceAccountOrganizationId = new Guid(GetClaimValue(claimsDict, Claims.Organization)); diff --git a/test/Core.Test/Context/CurrentContextTests.cs b/test/Core.Test/Context/CurrentContextTests.cs new file mode 100644 index 0000000000..b868d6ceaa --- /dev/null +++ b/test/Core.Test/Context/CurrentContextTests.cs @@ -0,0 +1,733 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Context; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Context; + +[SutProviderCustomize] +public class CurrentContextTests +{ + #region BuildAsync(HttpContext) Tests + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsHttpContext( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(httpContext, sutProvider.Sut.HttpContext); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_OnlyBuildsOnce( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + var firstContext = sutProvider.Sut.HttpContext; + + var secondHttpContext = new DefaultHttpContext(); + + // Act + await sutProvider.Sut.BuildAsync(secondHttpContext, globalSettings); + + // Assert + Assert.Equal(firstContext, sutProvider.Sut.HttpContext); + Assert.NotEqual(secondHttpContext, sutProvider.Sut.HttpContext); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsDeviceIdentifier( + SutProvider sutProvider, + string expectedValue) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + sutProvider.Sut.DeviceIdentifier = null; + // Arrange + httpContext.Request.Headers["Device-Identifier"] = expectedValue; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(expectedValue, sutProvider.Sut.DeviceIdentifier); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsCountryName( + SutProvider sutProvider, + string countryName) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + httpContext.Request.Headers["country-name"] = countryName; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(countryName, sutProvider.Sut.CountryName); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsDeviceType( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + var deviceType = DeviceType.Android; + httpContext.Request.Headers["Device-Type"] = ((int)deviceType).ToString(); + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(deviceType, sutProvider.Sut.DeviceType); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsCloudflareFlags( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + sutProvider.Sut.BotScore = null; + // Arrange + var botScore = 85; + httpContext.Request.Headers["X-Cf-Bot-Score"] = botScore.ToString(); + httpContext.Request.Headers["X-Cf-Worked-Proxied"] = "1"; + httpContext.Request.Headers["X-Cf-Is-Bot"] = "1"; + httpContext.Request.Headers["X-Cf-Maybe-Bot"] = "1"; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.True(sutProvider.Sut.CloudflareWorkerProxied); + Assert.True(sutProvider.Sut.IsBot); + Assert.True(sutProvider.Sut.MaybeBot); + Assert.Equal(botScore, sutProvider.Sut.BotScore); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsClientVersion( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + var version = "2024.1.0"; + httpContext.Request.Headers["Bitwarden-Client-Version"] = version; + httpContext.Request.Headers["Is-Prerelease"] = "1"; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(new Version(version), sutProvider.Sut.ClientVersion); + Assert.True(sutProvider.Sut.ClientVersionIsPrerelease); + } + + #endregion + + #region SetContextAsync Tests + + [Theory, BitAutoData] + public async Task SetContextAsync_NullUser_DoesNotThrow( + SutProvider sutProvider) + { + // Act & Assert + await sutProvider.Sut.SetContextAsync(null); + // Should not throw + } + + [Theory, BitAutoData] + public async Task SetContextAsync_UserWithNoClaims_DoesNotThrow( + SutProvider sutProvider) + { + // Arrange + var user = new ClaimsPrincipal(); + + // Act & Assert + await sutProvider.Sut.SetContextAsync(user); + // Should not throw + } + + [Theory, BitAutoData] + public async Task SetContextAsync_SendClient_ShortCircuits( + SutProvider sutProvider, + Guid userId) + { + // Arrange + sutProvider.Sut.UserId = null; + var claims = new List + { + new(Claims.Type, IdentityClientType.Send.ToString()), + new("sub", userId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(IdentityClientType.Send, sutProvider.Sut.IdentityClientType); + Assert.Null(sutProvider.Sut.UserId); // Should not be set for Send clients + } + + [Theory, BitAutoData] + public async Task SetContextAsync_RegularUser_SetsUserId( + SutProvider sutProvider, + Guid userId, + string clientId) + { + // Arrange + var claims = new List + { + new("sub", userId.ToString()), + new("client_id", clientId) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(userId, sutProvider.Sut.UserId); + Assert.Equal(clientId, sutProvider.Sut.ClientId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_InstallationClient_SetsInstallationId( + SutProvider sutProvider, + Guid installationId) + { + // Arrange + var claims = new List + { + new("client_id", "installation.12345"), + new("client_sub", installationId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(installationId, sutProvider.Sut.InstallationId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_OrganizationClient_SetsOrganizationId( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + var claims = new List + { + new("client_id", "organization.12345"), + new("client_sub", organizationId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(organizationId, sutProvider.Sut.OrganizationId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_ServiceAccount_SetsServiceAccountOrganizationId( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + var claims = new List + { + new(Claims.Type, IdentityClientType.ServiceAccount.ToString()), + new(Claims.Organization, organizationId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(IdentityClientType.ServiceAccount, sutProvider.Sut.IdentityClientType); + Assert.Equal(organizationId, sutProvider.Sut.ServiceAccountOrganizationId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_WithDeviceClaims_SetsDeviceInfo( + SutProvider sutProvider, + string deviceIdentifier) + { + // Arrange + var claims = new List + { + new(Claims.Device, deviceIdentifier), + new(Claims.DeviceType, ((int)DeviceType.iOS).ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(deviceIdentifier, sutProvider.Sut.DeviceIdentifier); + Assert.Equal(DeviceType.iOS, sutProvider.Sut.DeviceType); + } + + #endregion + + #region Organization Claims Tests + + [Theory] + [BitAutoData(Claims.OrganizationOwner, OrganizationUserType.Owner)] + [BitAutoData(Claims.OrganizationAdmin, OrganizationUserType.Admin)] + [BitAutoData(Claims.OrganizationUser, OrganizationUserType.User)] + public async Task SetContextAsync_OrganizationClaims_SetsOrganizations( + string userOrgAssociation, + OrganizationUserType userType, + SutProvider sutProvider, + Guid org1Id, + Guid org2Id) + { + // Arrange + var claims = new List + { + new(userOrgAssociation, org1Id.ToString()), + new(userOrgAssociation, org2Id.ToString()), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(2, sutProvider.Sut.Organizations.Count); + Assert.All(sutProvider.Sut.Organizations, org => Assert.Equal(userType, org.Type)); + Assert.Contains(sutProvider.Sut.Organizations, org => org.Id == org1Id); + Assert.Contains(sutProvider.Sut.Organizations, org => org.Id == org2Id); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_OrganizationCustomClaims_SetsOrganizationsWithPermissions( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + var claims = new List + { + new(Claims.OrganizationCustom, orgId.ToString()), + new("accesseventlogs", orgId.ToString()), + new("manageusers", orgId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Organizations); + var org = sutProvider.Sut.Organizations.First(); + Assert.Equal(OrganizationUserType.Custom, org.Type); + Assert.Equal(orgId, org.Id); + Assert.True(org.Permissions.AccessEventLogs); + Assert.True(org.Permissions.ManageUsers); + Assert.False(org.Permissions.ManageGroups); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_SecretsManagerAccess_SetsAccessSecretsManager( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + var claims = new List + { + new(Claims.OrganizationOwner, orgId.ToString()), + new(Claims.SecretsManagerAccess, orgId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Organizations); + Assert.True(sutProvider.Sut.Organizations.First().AccessSecretsManager); + } + + #endregion + + #region Provider Claims Tests + + [Theory, BitAutoData] + public async Task SetContextAsync_ProviderAdminClaims_SetsProviders( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + var claims = new List + { + new(Claims.ProviderAdmin, providerId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Providers); + Assert.Equal(ProviderUserType.ProviderAdmin, sutProvider.Sut.Providers.First().Type); + Assert.Equal(providerId, sutProvider.Sut.Providers.First().Id); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_ProviderServiceUserClaims_SetsProviders( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + var claims = new List + { + new(Claims.ProviderServiceUser, providerId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Providers); + Assert.Equal(ProviderUserType.ServiceUser, sutProvider.Sut.Providers.First().Type); + Assert.Equal(providerId, sutProvider.Sut.Providers.First().Id); + } + + #endregion + + #region Organization Permission Tests + + [Theory, BitAutoData] + public async Task OrganizationUser_WithDirectAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.User } + }; + + // Act + var result = await sutProvider.Sut.OrganizationUser(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task OrganizationUser_WithoutAccess_ReturnsFalse( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List(); + + // Act + var result = await sutProvider.Sut.OrganizationUser(orgId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task OrganizationAdmin_WithAdminAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.Admin } + }; + + // Act + var result = await sutProvider.Sut.OrganizationAdmin(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task OrganizationOwner_WithOwnerAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.Owner } + }; + + // Act + var result = await sutProvider.Sut.OrganizationOwner(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task OrganizationCustom_WithCustomAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.Custom } + }; + + // Act + var result = await sutProvider.Sut.OrganizationCustom(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task AccessEventLogs_WithPermission_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() + { + Id = orgId, + Type = OrganizationUserType.Custom, + Permissions = new Permissions { AccessEventLogs = true } + } + }; + + // Act + var result = await sutProvider.Sut.AccessEventLogs(orgId); + + // Assert + Assert.True(result); + } + + #endregion + + #region Provider Permission Tests + + [Theory, BitAutoData] + public void ProviderProviderAdmin_WithAdminAccess_ReturnsTrue( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.Sut.Providers = new List + { + new() { Id = providerId, Type = ProviderUserType.ProviderAdmin } + }; + + // Act + var result = sutProvider.Sut.ProviderProviderAdmin(providerId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void ProviderUser_WithAnyAccess_ReturnsTrue( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.Sut.Providers = new List + { + new() { Id = providerId, Type = ProviderUserType.ServiceUser } + }; + + // Act + var result = sutProvider.Sut.ProviderUser(providerId); + + // Assert + Assert.True(result); + } + + #endregion + + #region Secrets Manager Tests + + [Theory, BitAutoData] + public void AccessSecretsManager_WithServiceAccount_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.ServiceAccountOrganizationId = orgId; + + // Act + var result = sutProvider.Sut.AccessSecretsManager(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void AccessSecretsManager_WithOrgAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, AccessSecretsManager = true } + }; + + // Act + var result = sutProvider.Sut.AccessSecretsManager(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void AccessSecretsManager_WithoutAccess_ReturnsFalse( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, AccessSecretsManager = false } + }; + + // Act + var result = sutProvider.Sut.AccessSecretsManager(orgId); + + // Assert + Assert.False(result); + } + + #endregion + + #region Membership Loading Tests + + [Theory, BitAutoData] + public async Task OrganizationMembershipAsync_LoadsFromRepository( + SutProvider sutProvider, + Guid userId, + List userOrgs) + { + // Arrange + sutProvider.Sut.UserId = userId; + sutProvider.Sut.Organizations = null; + var organizationUserRepository = Substitute.For(); + userOrgs.ForEach(org => org.Status = OrganizationUserStatusType.Confirmed); + + // Test complains about the JSON object that we store permissions as, so just set to empty object to pass the test. + userOrgs.ForEach(org => org.Permissions = "{}"); + organizationUserRepository.GetManyDetailsByUserAsync(userId) + .Returns(userOrgs); + + // Act + var result = await sutProvider.Sut.OrganizationMembershipAsync(organizationUserRepository, userId); + + // Assert + Assert.Equal(userOrgs.Count, result.Count); + Assert.Equal(userId, sutProvider.Sut.UserId); + await organizationUserRepository.Received(1).GetManyDetailsByUserAsync(userId); + } + + [Theory, BitAutoData] + public async Task ProviderMembershipAsync_LoadsFromRepository( + SutProvider sutProvider, + Guid userId, + List userProviders) + { + // Arrange + sutProvider.Sut.UserId = userId; + sutProvider.Sut.Providers = null; + + var providerUserRepository = Substitute.For(); + userProviders.ForEach(provider => provider.Status = ProviderUserStatusType.Confirmed); + + // Test complains about the JSON object that we store permissions as, so just set to empty object to pass the test. + userProviders.ForEach(provider => provider.Permissions = "{}"); + providerUserRepository.GetManyByUserAsync(userId) + .Returns(userProviders); + + // Act + var result = await sutProvider.Sut.ProviderMembershipAsync(providerUserRepository, userId); + + // Assert + Assert.Equal(userProviders.Count, result.Count); + Assert.Equal(userId, sutProvider.Sut.UserId); + await providerUserRepository.Received(1).GetManyByUserAsync(userId); + } + + #endregion + + #region Utility Tests + + [Theory, BitAutoData] + public void GetOrganization_WithExistingOrg_ReturnsOrganization( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + var org = new CurrentContextOrganization { Id = orgId }; + sutProvider.Sut.Organizations = new List { org }; + + // Act + var result = sutProvider.Sut.GetOrganization(orgId); + + // Assert + Assert.Equal(org, result); + } + + [Theory, BitAutoData] + public void GetOrganization_WithNonExistingOrg_ReturnsNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List(); + + // Act + var result = sutProvider.Sut.GetOrganization(orgId); + + // Assert + Assert.Null(result); + } + + #endregion +} From 18aed0bd798c20abf82c64b5e17a94e483e6d23c Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 12 Sep 2025 10:41:53 -0500 Subject: [PATCH 69/85] Added conditional subject and button text to invite email. (#6304) * Added conditional subject and button text to invite email. * Added feature flag. --- ...uthorizationHandlerCollectionExtensions.cs | 8 ++-- .../SendOrganizationInvitesCommand.cs | 6 ++- src/Core/Constants.cs | 1 + .../OrganizationUserInvited.html.hbs | 2 +- .../Models/Mail/OrganizationInvitesInfo.cs | 5 +++ .../Mail/OrganizationUserInvitedViewModel.cs | 42 +++++++++++++++++++ .../Implementations/HandlebarsMailService.cs | 38 +++++++++++++---- 7 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs index 70cbc0d1a4..ed628105e0 100644 --- a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -13,9 +13,9 @@ public static class AuthorizationHandlerCollectionExtensions services.TryAddEnumerable([ ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ]); + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index cd5066d11b..69b968d438 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -22,7 +22,8 @@ public class SendOrganizationInvitesCommand( IPolicyRepository policyRepository, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, - IMailService mailService) : ISendOrganizationInvitesCommand + IMailService mailService, + IFeatureService featureService) : ISendOrganizationInvitesCommand { public async Task SendInvitesAsync(SendInvitesRequest request) { @@ -71,12 +72,15 @@ public class SendOrganizationInvitesCommand( var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); + var isSubjectFeatureEnabled = featureService.IsEnabled(FeatureFlagKeys.InviteEmailImprovements); + return new OrganizationInvitesInfo( organization, orgSsoEnabled, orgSsoLoginRequiredPolicyEnabled, orgUsersWithExpTokens, orgUserHasExistingUserDict, + isSubjectFeatureEnabled, initOrganization ); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index bef947b2b7..9f16d12950 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,6 +134,7 @@ public static class FeatureFlagKeys public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; + public const string InviteEmailImprovements = "pm-25644-update-join-organization-subject-line"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs index 33c3a9256d..f2594a4c12 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs @@ -3,7 +3,7 @@ - Join Organization Now + {{JoinOrganizationButtonText}} diff --git a/src/Core/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs index d1c05605e5..c31e00c184 100644 --- a/src/Core/Models/Mail/OrganizationInvitesInfo.cs +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -15,6 +15,7 @@ public class OrganizationInvitesInfo bool orgSsoLoginRequiredPolicyEnabled, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs, Dictionary orgUserHasExistingUserDict, + bool isSubjectFeatureEnabled = false, bool initOrganization = false ) { @@ -29,6 +30,8 @@ public class OrganizationInvitesInfo OrgUserTokenPairs = orgUserTokenPairs; OrgUserHasExistingUserDict = orgUserHasExistingUserDict; + + IsSubjectFeatureEnabled = isSubjectFeatureEnabled; } public string OrganizationName { get; } @@ -38,6 +41,8 @@ public class OrganizationInvitesInfo public string OrgSsoIdentifier { get; } public bool OrgSsoLoginRequiredPolicyEnabled { get; } + public bool IsSubjectFeatureEnabled { get; } + public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; } public Dictionary OrgUserHasExistingUserDict { get; } diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index 82f05af9bd..e43d5a72bd 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -22,6 +22,7 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel GlobalSettings globalSettings) { var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!"; + return new OrganizationUserInvitedViewModel { TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", @@ -48,6 +49,45 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel }; } + public static OrganizationUserInvitedViewModel CreateFromInviteInfo_v2( + OrganizationInvitesInfo orgInvitesInfo, + OrganizationUser orgUser, + ExpiringToken expiringToken, + GlobalSettings globalSettings) + { + const string freeOrgTitle = "A Bitwarden member invited you to an organization. " + + "Join now to start securing your passwords!"; + + var userHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id]; + + return new OrganizationUserInvitedViewModel + { + TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", + TitleSecondBold = + orgInvitesInfo.IsFreeOrg + ? string.Empty + : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), + TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", + OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), + Email = WebUtility.UrlEncode(orgUser.Email), + OrganizationId = orgUser.OrganizationId.ToString(), + OrganizationUserId = orgUser.Id.ToString(), + Token = WebUtility.UrlEncode(expiringToken.Token), + ExpirationDate = + $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC", + OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName), + WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash, + SiteName = globalSettings.SiteName, + InitOrganization = orgInvitesInfo.InitOrganization, + OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier, + OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled, + OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled, + OrgUserHasExistingUser = userHasExistingUser, + JoinOrganizationButtonText = userHasExistingUser || orgInvitesInfo.IsFreeOrg ? "Accept invitation" : "Finish account setup", + IsFreeOrg = orgInvitesInfo.IsFreeOrg + }; + } + public string OrganizationName { get; set; } public string OrganizationId { get; set; } public string OrganizationUserId { get; set; } @@ -60,6 +100,8 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel public bool OrgSsoEnabled { get; set; } public bool OrgSsoLoginRequiredPolicyEnabled { get; set; } public bool OrgUserHasExistingUser { get; set; } + public string JoinOrganizationButtonText { get; set; } = "Join Organization"; + public bool IsFreeOrg { get; set; } public string Url { diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 0410bad19e..89a613b7ed 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -351,21 +351,43 @@ public class HandlebarsMailService : IMailService public async Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo) { - MailQueueMessage CreateMessage(string email, object model) - { - var message = CreateDefaultMessage($"Join {orgInvitesInfo.OrganizationName}", email); - return new MailQueueMessage(message, "OrganizationUserInvited", model); - } - var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair => { Debug.Assert(orgUserTokenPair.OrgUser.Email is not null); - var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo( - orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings); + + var orgUserInviteViewModel = orgInvitesInfo.IsSubjectFeatureEnabled + ? OrganizationUserInvitedViewModel.CreateFromInviteInfo_v2( + orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings) + : OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, + orgUserTokenPair.Token, _globalSettings); + return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); }); await EnqueueMailAsync(messageModels); + return; + + MailQueueMessage CreateMessage(string email, OrganizationUserInvitedViewModel model) + { + var subject = $"Join {model.OrganizationName}"; + + if (orgInvitesInfo.IsSubjectFeatureEnabled) + { + ArgumentNullException.ThrowIfNull(model); + + subject = model! switch + { + { IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization", + { IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager", + { IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization", + { IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you" + }; + } + + var message = CreateDefaultMessage(subject, email); + + return new MailQueueMessage(message, "OrganizationUserInvited", model); + } } public async Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) From 4e64d35f89bc9aa9b3c6630aa6228ae4d79d4de2 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Fri, 12 Sep 2025 13:24:30 -0400 Subject: [PATCH 70/85] [PM-19151] [PM-19161] Innovation/archive/server (#5672) * Added the ArchivedDate to cipher entity and response model * Created migration scripts for sqlserver and ef core migration to add the ArchivedDate column --------- Co-authored-by: gbubemismith Co-authored-by: SmithThe4th Co-authored-by: Shane Co-authored-by: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Co-authored-by: jng --- .../Vault/Controllers/CiphersController.cs | 89 + .../Models/Request/CipherRequestModel.cs | 14 + .../Models/Response/CipherResponseModel.cs | 2 + src/Core/Constants.cs | 3 + .../Vault/Commands/ArchiveCiphersCommand.cs | 61 + .../Interfaces/IArchiveCiphersCommand.cs | 14 + .../Interfaces/IUnarchiveCiphersCommand.cs | 14 + .../Vault/Commands/UnarchiveCiphersCommand.cs | 60 + src/Core/Vault/Entities/Cipher.cs | 1 + src/Core/Vault/Enums/CipherStateAction.cs | 2 + .../Vault/Repositories/ICipherRepository.cs | 2 + .../Services/Implementations/CipherService.cs | 13 +- .../Vault/VaultServiceCollectionExtensions.cs | 2 + .../Vault/Repositories/CipherRepository.cs | 28 +- .../Queries/UserCipherDetailsQuery.cs | 9 +- .../Vault/Repositories/CipherRepository.cs | 77 +- .../Queries/CipherDetailsQuery.cs | 1 + src/Sql/dbo/Vault/Functions/CipherDetails.sql | 5 +- .../Cipher/CipherDetails_Create.sql | 9 +- .../CipherDetails_CreateWithCollections.sql | 5 +- .../Cipher/CipherDetails_ReadByIdUserId.sql | 4 +- .../Cipher/CipherDetails_Update.sql | 6 +- .../Cipher/Cipher_Archive.sql | 39 + .../Cipher/Cipher_Create.sql | 9 +- .../Cipher/Cipher_CreateWithCollections.sql | 7 +- .../Cipher/Cipher_Unarchive.sql | 39 + .../Cipher/Cipher_Update.sql | 6 +- .../Cipher/Cipher_UpdateWithCollections.sql | 8 +- src/Sql/dbo/Vault/Tables/Cipher.sql | 2 + .../Vault/AutoFixture/CipherFixtures.cs | 4 + .../Commands/ArchiveCiphersCommandTest.cs | 49 + .../Commands/UnarchiveCiphersCommandTest.cs | 49 + .../Repositories/CipherRepositoryTests.cs | 30 + .../2025-09-09_00_CipherArchiveInit.sql | 576 ++++ ...152208_AddArchivedDateToCipher.Designer.cs | 3020 ++++++++++++++++ .../20250829152208_AddArchivedDateToCipher.cs | 27 + .../DatabaseContextModelSnapshot.cs | 3 + ...152204_AddArchivedDateToCipher.Designer.cs | 3026 +++++++++++++++++ .../20250829152204_AddArchivedDateToCipher.cs | 27 + .../DatabaseContextModelSnapshot.cs | 3 + ...152213_AddArchivedDateToCipher.Designer.cs | 3009 ++++++++++++++++ .../20250829152213_AddArchivedDateToCipher.cs | 27 + .../DatabaseContextModelSnapshot.cs | 3 + 43 files changed, 10342 insertions(+), 42 deletions(-) create mode 100644 src/Core/Vault/Commands/ArchiveCiphersCommand.cs create mode 100644 src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs create mode 100644 src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs create mode 100644 src/Core/Vault/Commands/UnarchiveCiphersCommand.cs create mode 100644 src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql create mode 100644 src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql create mode 100644 test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs create mode 100644 test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs create mode 100644 util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql create mode 100644 util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs create mode 100644 util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs create mode 100644 util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 84e0488e5a..db3d5fb357 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -20,6 +20,7 @@ using Bit.Core.Settings; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.Permissions; +using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; @@ -48,6 +49,8 @@ public class CiphersController : Controller private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; + private readonly IArchiveCiphersCommand _archiveCiphersCommand; + private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; private readonly IFeatureService _featureService; public CiphersController( @@ -63,6 +66,8 @@ public class CiphersController : Controller IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository, + IArchiveCiphersCommand archiveCiphersCommand, + IUnarchiveCiphersCommand unarchiveCiphersCommand, IFeatureService featureService) { _cipherRepository = cipherRepository; @@ -77,6 +82,8 @@ public class CiphersController : Controller _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; + _archiveCiphersCommand = archiveCiphersCommand; + _unarchiveCiphersCommand = unarchiveCiphersCommand; _featureService = featureService; } @@ -846,6 +853,47 @@ public class CiphersController : Controller } } + [HttpPut("{id}/archive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task PutArchive(Guid id) + { + var userId = _userService.GetProperUserId(User).Value; + + var archivedCipherOrganizationDetails = await _archiveCiphersCommand.ArchiveManyAsync([id], userId); + + if (archivedCipherOrganizationDetails.Count == 0) + { + throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it."); + } + + return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp); + } + + [HttpPut("archive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only archive up to 500 items at a time."); + } + + var userId = _userService.GetProperUserId(User).Value; + + var cipherIdsToArchive = new HashSet(model.Ids); + + var archivedCiphers = await _archiveCiphersCommand.ArchiveManyAsync(cipherIdsToArchive, userId); + + if (archivedCiphers.Count == 0) + { + throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them."); + } + + var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + + return new ListResponseModel(responses); + } + [HttpDelete("{id}")] [HttpPost("{id}/delete")] public async Task Delete(Guid id) @@ -979,6 +1027,47 @@ public class CiphersController : Controller await _cipherService.SoftDeleteManyAsync(cipherIds, userId, new Guid(model.OrganizationId), true); } + [HttpPut("{id}/unarchive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task PutUnarchive(Guid id) + { + var userId = _userService.GetProperUserId(User).Value; + + var unarchivedCipherDetails = await _unarchiveCiphersCommand.UnarchiveManyAsync([id], userId); + + if (unarchivedCipherDetails.Count == 0) + { + throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it."); + } + + return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp); + } + + [HttpPut("unarchive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only unarchive up to 500 items at a time."); + } + + var userId = _userService.GetProperUserId(User).Value; + + var cipherIdsToUnarchive = new HashSet(model.Ids); + + var unarchivedCipherOrganizationDetails = await _unarchiveCiphersCommand.UnarchiveManyAsync(cipherIdsToUnarchive, userId); + + if (unarchivedCipherOrganizationDetails.Count == 0) + { + throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it."); + } + + var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + + return new ListResponseModel(responses); + } + [HttpPut("{id}/restore")] public async Task PutRestore(Guid id) { diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 187fd13e30..467be6e356 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -46,6 +46,7 @@ public class CipherRequestModel public CipherSecureNoteModel SecureNote { get; set; } public CipherSSHKeyModel SSHKey { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; + public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -99,6 +100,7 @@ public class CipherRequestModel existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; + existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -316,6 +318,12 @@ public class CipherCollectionsRequestModel public IEnumerable CollectionIds { get; set; } } +public class CipherBulkArchiveRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} + public class CipherBulkDeleteRequestModel { [Required] @@ -323,6 +331,12 @@ public class CipherBulkDeleteRequestModel public string OrganizationId { get; set; } } +public class CipherBulkUnarchiveRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} + public class CipherBulkRestoreRequestModel { [Required] diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 9d053f6697..3e4e8da512 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -74,6 +74,7 @@ public class CipherMiniResponseModel : ResponseModel DeletedDate = cipher.DeletedDate; Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; + ArchivedDate = cipher.ArchivedDate; } public Guid Id { get; set; } @@ -96,6 +97,7 @@ public class CipherMiniResponseModel : ResponseModel public DateTime? DeletedDate { get; set; } public CipherRepromptType Reprompt { get; set; } public string Key { get; set; } + public DateTime? ArchivedDate { get; set; } } public class CipherResponseModel : CipherMiniResponseModel diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9f16d12950..ed9ee02dad 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -229,6 +229,9 @@ public static class FeatureFlagKeys public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; + /* Innovation Team */ + public const string ArchiveVaultItems = "pm-19148-innovation-archive"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) diff --git a/src/Core/Vault/Commands/ArchiveCiphersCommand.cs b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs new file mode 100644 index 0000000000..6c8e0fcf75 --- /dev/null +++ b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs @@ -0,0 +1,61 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Commands; + +public class ArchiveCiphersCommand : IArchiveCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IPushNotificationService _pushService; + + public ArchiveCiphersCommand( + ICipherRepository cipherRepository, + IPushNotificationService pushService + ) + { + _cipherRepository = cipherRepository; + _pushService = pushService; + } + + public async Task> ArchiveManyAsync(IEnumerable cipherIds, + Guid archivingUserId) + { + var cipherIdEnumerable = cipherIds as Guid[] ?? cipherIds.ToArray(); + if (cipherIds == null || cipherIdEnumerable.Length == 0) + throw new BadRequestException("No cipher ids provided."); + + var cipherIdsSet = new HashSet(cipherIdEnumerable); + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(archivingUserId); + + if (ciphers == null || ciphers.Count == 0) + { + return []; + } + + var archivingCiphers = ciphers + .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, OrganizationId: null, ArchivedDate: null }) + .ToList(); + + var revisionDate = await _cipherRepository.ArchiveAsync(archivingCiphers.Select(c => c.Id), archivingUserId); + + // Adding specifyKind because revisionDate is currently coming back as Unspecified from the database + revisionDate = DateTime.SpecifyKind(revisionDate, DateTimeKind.Utc); + + archivingCiphers.ForEach(c => + { + c.RevisionDate = revisionDate; + c.ArchivedDate = revisionDate; + }); + + // Will not log an event because the archive feature is limited to individual ciphers, and event logs only apply to organization ciphers. + // Add event logging here if this is expanded to organization ciphers in the future. + + await _pushService.PushSyncCiphersAsync(archivingUserId); + + return archivingCiphers; + } +} diff --git a/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs b/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs new file mode 100644 index 0000000000..63df62f160 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IArchiveCiphersCommand +{ + /// + /// Archives a cipher. This fills in the ArchivedDate property on a Cipher. + /// + /// Cipher ID to archive. + /// User ID to check against the Ciphers that are trying to be archived. + /// + public Task> ArchiveManyAsync(IEnumerable cipherIds, Guid archivingUserId); +} diff --git a/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs new file mode 100644 index 0000000000..4ed683c0a2 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IUnarchiveCiphersCommand +{ + /// + /// Unarchives a cipher. This nulls the ArchivedDate property on a Cipher. + /// + /// Cipher ID to unarchive. + /// User ID to check against the Ciphers that are trying to be unarchived. + /// + public Task> UnarchiveManyAsync(IEnumerable cipherIds, Guid unarchivingUserId); +} diff --git a/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs new file mode 100644 index 0000000000..83dcbab4e1 --- /dev/null +++ b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Commands; + +public class UnarchiveCiphersCommand : IUnarchiveCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IPushNotificationService _pushService; + + public UnarchiveCiphersCommand( + ICipherRepository cipherRepository, + IPushNotificationService pushService + ) + { + _cipherRepository = cipherRepository; + _pushService = pushService; + } + + public async Task> UnarchiveManyAsync(IEnumerable cipherIds, + Guid unarchivingUserId) + { + var cipherIdEnumerable = cipherIds as Guid[] ?? cipherIds.ToArray(); + if (cipherIds == null || cipherIdEnumerable.Length == 0) + throw new BadRequestException("No cipher ids provided."); + + var cipherIdsSet = new HashSet(cipherIdEnumerable); + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(unarchivingUserId); + + if (ciphers == null || ciphers.Count == 0) + { + return []; + } + + var unarchivingCiphers = ciphers + .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, ArchivedDate: not null }) + .ToList(); + + var revisionDate = + await _cipherRepository.UnarchiveAsync(unarchivingCiphers.Select(c => c.Id), unarchivingUserId); + // Adding specifyKind because revisionDate is currently coming back as Unspecified from the database + revisionDate = DateTime.SpecifyKind(revisionDate, DateTimeKind.Utc); + + unarchivingCiphers.ForEach(c => + { + c.RevisionDate = revisionDate; + c.ArchivedDate = null; + }); + // Will not log an event because the archive feature is limited to individual ciphers, and event logs only apply to organization ciphers. + // Add event logging here if this is expanded to organization ciphers in the future. + + await _pushService.PushSyncCiphersAsync(unarchivingUserId); + + return unarchivingCiphers; + } +} diff --git a/src/Core/Vault/Entities/Cipher.cs b/src/Core/Vault/Entities/Cipher.cs index 8d8282d83c..f6afc090bb 100644 --- a/src/Core/Vault/Entities/Cipher.cs +++ b/src/Core/Vault/Entities/Cipher.cs @@ -25,6 +25,7 @@ public class Cipher : ITableObject, ICloneable public DateTime? DeletedDate { get; set; } public Enums.CipherRepromptType? Reprompt { get; set; } public string Key { get; set; } + public DateTime? ArchivedDate { get; set; } public void SetNewId() { diff --git a/src/Core/Vault/Enums/CipherStateAction.cs b/src/Core/Vault/Enums/CipherStateAction.cs index adbc78c06c..d63315e63f 100644 --- a/src/Core/Vault/Enums/CipherStateAction.cs +++ b/src/Core/Vault/Enums/CipherStateAction.cs @@ -3,6 +3,8 @@ public enum CipherStateAction { Restore, + Unarchive, + Archive, SoftDelete, HardDelete, } diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index e442477921..32acf3cbc9 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -25,6 +25,7 @@ public interface ICipherRepository : IRepository Task ReplaceAsync(Cipher obj, IEnumerable collectionIds); Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite); Task UpdateAttachmentAsync(CipherAttachment attachment); + Task ArchiveAsync(IEnumerable ids, Guid userId); Task DeleteAttachmentAsync(Guid cipherId, string attachmentId); Task DeleteAsync(IEnumerable ids, Guid userId); Task DeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); @@ -56,6 +57,7 @@ public interface ICipherRepository : IRepository IEnumerable collectionCiphers, IEnumerable collectionUsers); Task SoftDeleteAsync(IEnumerable ids, Guid userId); Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); + Task UnarchiveAsync(IEnumerable ids, Guid userId); Task RestoreAsync(IEnumerable ids, Guid userId); Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task DeleteDeletedAsync(DateTime deletedDateBefore); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index e0b121fdd3..ebfb2a4a2a 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -481,7 +481,7 @@ public class CipherService : ICipherService throw new NotFoundException(); } await _cipherRepository.DeleteByOrganizationIdAsync(organizationId); - await _eventService.LogOrganizationEventAsync(org, Bit.Core.Enums.EventType.Organization_PurgedVault); + await _eventService.LogOrganizationEventAsync(org, EventType.Organization_PurgedVault); } public async Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId) @@ -697,7 +697,7 @@ public class CipherService : ICipherService await _collectionCipherRepository.UpdateCollectionsAsync(cipher.Id, savingUserId, collectionIds); } - await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_UpdatedCollections); + await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_UpdatedCollections); // push await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds); @@ -786,8 +786,8 @@ public class CipherService : ICipherService } var cipherIdsSet = new HashSet(cipherIds); - var restoringCiphers = new List(); - DateTime? revisionDate; + List restoringCiphers; + DateTime? revisionDate; // TODO: Make this not nullable if (orgAdmin && organizationId.HasValue) { @@ -971,6 +971,11 @@ public class CipherService : ICipherService throw new BadRequestException("One or more ciphers do not belong to you."); } + if (cipher.ArchivedDate.HasValue) + { + throw new BadRequestException("Cipher cannot be shared with organization because it is archived."); + } + var attachments = cipher.GetAttachments(); var hasAttachments = attachments?.Any() ?? false; var org = await _organizationRepository.GetByIdAsync(organizationId); diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index 1acc74959d..93e86c0208 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -24,6 +24,8 @@ public static class VaultServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 08593191f1..c741495f8e 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -240,11 +240,24 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task ArchiveAsync(IEnumerable ids, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + $"[{Schema}].[Cipher_Archive]", + new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) { using (var connection = new SqlConnection(ConnectionString)) { - var results = await connection.ExecuteAsync( + await connection.ExecuteAsync( $"[{Schema}].[Cipher_DeleteAttachment]", new { Id = cipherId, AttachmentId = attachmentId }, commandType: CommandType.StoredProcedure); @@ -830,6 +843,19 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task UnarchiveAsync(IEnumerable ids, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + $"[{Schema}].[Cipher_Unarchive]", + new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + public async Task RestoreAsync(IEnumerable ids, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs index 98d555ff19..b196a07e9b 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs @@ -71,7 +71,8 @@ public class UserCipherDetailsQuery : IQuery Manage = cu == null ? (cg != null && cg.Manage == true) : cu.Manage == true, OrganizationUseTotp = o.UseTotp, c.Reprompt, - c.Key + c.Key, + c.ArchivedDate }; var query2 = from c in dbContext.Ciphers @@ -94,7 +95,8 @@ public class UserCipherDetailsQuery : IQuery Manage = true, OrganizationUseTotp = false, c.Reprompt, - c.Key + c.Key, + c.ArchivedDate }; var union = query.Union(query2).Select(c => new CipherDetails @@ -115,7 +117,8 @@ public class UserCipherDetailsQuery : IQuery ViewPassword = c.ViewPassword, Manage = c.Manage, OrganizationUseTotp = c.OrganizationUseTotp, - Key = c.Key + Key = c.Key, + ArchivedDate = c.ArchivedDate }); return union; } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 1a137c5f4b..4b2d09f87b 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -230,7 +230,7 @@ public class CipherRepository : Repository ids, Guid userId) { - await ToggleCipherStates(ids, userId, CipherStateAction.HardDelete); + await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.HardDelete); } public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) @@ -508,7 +508,8 @@ public class CipherRepository : Repository UnarchiveAsync(IEnumerable ids, Guid userId) + { + return await ToggleArchiveCipherStatesAsync(ids, userId, CipherStateAction.Unarchive); + } + public async Task RestoreAsync(IEnumerable ids, Guid userId) { - return await ToggleCipherStates(ids, userId, CipherStateAction.Restore); + return await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.Restore); } public async Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId) @@ -781,20 +787,25 @@ public class CipherRepository : Repository ids, Guid userId) + public async Task ArchiveAsync(IEnumerable ids, Guid userId) { - await ToggleCipherStates(ids, userId, CipherStateAction.SoftDelete); + return await ToggleArchiveCipherStatesAsync(ids, userId, CipherStateAction.Archive); } - private async Task ToggleCipherStates(IEnumerable ids, Guid userId, CipherStateAction action) + public async Task SoftDeleteAsync(IEnumerable ids, Guid userId) { - static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) + await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.SoftDelete); + } + + private async Task ToggleArchiveCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) + { + static bool FilterArchivedDate(CipherStateAction action, CipherDetails ucd) { return action switch { - CipherStateAction.Restore => ucd.DeletedDate != null, - CipherStateAction.SoftDelete => ucd.DeletedDate == null, - _ => true, + CipherStateAction.Unarchive => ucd.ArchivedDate != null, + CipherStateAction.Archive => ucd.ArchivedDate == null, + _ => true }; } @@ -802,8 +813,49 @@ public class CipherRepository : Repository ids.Contains(c.Id))).ToListAsync(); - var query = from ucd in await (userCipherDetailsQuery.Run(dbContext)).ToListAsync() + var cipherEntitiesToCheck = await dbContext.Ciphers.Where(c => ids.Contains(c.Id)).ToListAsync(); + var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() + join c in cipherEntitiesToCheck + on ucd.Id equals c.Id + where ucd.Edit && FilterArchivedDate(action, ucd) + select c; + + var utcNow = DateTime.UtcNow; + var cipherIdsToModify = query.Select(c => c.Id); + var cipherEntitiesToModify = dbContext.Ciphers.Where(x => cipherIdsToModify.Contains(x.Id)); + + await cipherEntitiesToModify.ForEachAsync(cipher => + { + dbContext.Attach(cipher); + cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow; + cipher.RevisionDate = utcNow; + }); + + await dbContext.UserBumpAccountRevisionDateAsync(userId); + await dbContext.SaveChangesAsync(); + + return utcNow; + } + } + + private async Task ToggleDeleteCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) + { + static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) + { + return action switch + { + CipherStateAction.Restore => ucd.DeletedDate != null, + CipherStateAction.SoftDelete => ucd.DeletedDate == null, + _ => true + }; + } + + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var userCipherDetailsQuery = new UserCipherDetailsQuery(userId); + var cipherEntitiesToCheck = await dbContext.Ciphers.Where(c => ids.Contains(c.Id)).ToListAsync(); + var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() join c in cipherEntitiesToCheck on ucd.Id equals c.Id where ucd.Edit && FilterDeletedDate(action, ucd) @@ -841,6 +893,7 @@ public class CipherRepository : Repository FolderId = (_ignoreFolders || !_userId.HasValue || c.Folders == null || !c.Folders.ToLowerInvariant().Contains(_userId.Value.ToString())) ? null : CoreHelpers.LoadClassFromJsonData>(c.Folders)[_userId.Value], + ArchivedDate = c.ArchivedDate, }; return query; } diff --git a/src/Sql/dbo/Vault/Functions/CipherDetails.sql b/src/Sql/dbo/Vault/Functions/CipherDetails.sql index 5577ff4787..ed92c11cb6 100644 --- a/src/Sql/dbo/Vault/Functions/CipherDetails.sql +++ b/src/Sql/dbo/Vault/Functions/CipherDetails.sql @@ -27,6 +27,7 @@ SELECT END [FolderId], C.[DeletedDate], C.[Reprompt], - C.[Key] + C.[Key], + C.[ArchivedDate] FROM - [dbo].[Cipher] C \ No newline at end of file + [dbo].[Cipher] C diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql index d0e08fcd08..254110f059 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql @@ -17,7 +17,8 @@ @OrganizationUseTotp BIT, -- not used @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -38,7 +39,8 @@ BEGIN [RevisionDate], [DeletedDate], [Reprompt], - [Key] + [Key], + [ArchivedDate] ) VALUES ( @@ -53,7 +55,8 @@ BEGIN @RevisionDate, @DeletedDate, @Reprompt, - @Key + @Key, + @ArchivedDate ) IF @OrganizationId IS NOT NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql index 6e61d3d385..ee7e00b32a 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql @@ -18,14 +18,15 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage, - @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key + @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql index 7e2c893a41..2646159b62 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql @@ -20,6 +20,7 @@ SELECT [Reprompt], [Key], [OrganizationUseTotp], + [ArchivedDate], MAX ([Edit]) AS [Edit], MAX ([ViewPassword]) AS [ViewPassword], MAX ([Manage]) AS [Manage] @@ -41,5 +42,6 @@ SELECT [DeletedDate], [Reprompt], [Key], - [OrganizationUseTotp] + [OrganizationUseTotp], + [ArchivedDate] END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql index 8fc95eb302..c17f5761ff 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql @@ -17,7 +17,8 @@ @OrganizationUseTotp BIT, -- not used @DeletedDate DATETIME2(2), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -55,7 +56,8 @@ BEGIN [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, - [Key] = @Key + [Key] = @Key, + [ArchivedDate] = @ArchivedDate WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql new file mode 100644 index 0000000000..68f11c0d4f --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[Cipher_Archive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql index 676c013cc8..eb49136895 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql @@ -11,7 +11,8 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -29,7 +30,8 @@ BEGIN [RevisionDate], [DeletedDate], [Reprompt], - [Key] + [Key], + [ArchivedDate] ) VALUES ( @@ -44,7 +46,8 @@ BEGIN @RevisionDate, @DeletedDate, @Reprompt, - @Key + @Key, + @ArchivedDate ) IF @OrganizationId IS NOT NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql index 775ab0e0a0..ac7be1bbae 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql @@ -12,14 +12,15 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, - @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql new file mode 100644 index 0000000000..c2b7b10619 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[Cipher_Unarchive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NOT NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = NULL, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql index 8baf1b5f0f..912badc906 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql @@ -11,7 +11,8 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -30,7 +31,8 @@ BEGIN [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, [Reprompt] = @Reprompt, - [Key] = @Key + [Key] = @Key, + [ArchivedDate] = @ArchivedDate WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql index 0a0c980e4a..55852c4d27 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql @@ -12,7 +12,8 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -37,8 +38,7 @@ BEGIN [Data] = @Data, [Attachments] = @Attachments, [RevisionDate] = @RevisionDate, - [DeletedDate] = @DeletedDate, - [Key] = @Key + [DeletedDate] = @DeletedDate, [Key] = @Key, [ArchivedDate] = @ArchivedDate -- No need to update CreationDate, Favorites, Folders, or Type since that data will not change WHERE [Id] = @Id @@ -54,4 +54,4 @@ BEGIN EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId SELECT 0 -- 0 = Success -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql index 38dd47d21f..d69035a0a9 100644 --- a/src/Sql/dbo/Vault/Tables/Cipher.sql +++ b/src/Sql/dbo/Vault/Tables/Cipher.sql @@ -13,6 +13,7 @@ CREATE TABLE [dbo].[Cipher] ( [DeletedDate] DATETIME2 (7) NULL, [Reprompt] TINYINT NULL, [Key] VARCHAR(MAX) NULL, + [ArchivedDate] DATETIME2 (7) NULL, CONSTRAINT [PK_Cipher] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_Cipher_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_Cipher_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) @@ -34,4 +35,5 @@ GO CREATE NONCLUSTERED INDEX [IX_Cipher_DeletedDate] ON [dbo].[Cipher]([DeletedDate] ASC); + GO diff --git a/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs b/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs index d3fe5f7f8e..f2feb82927 100644 --- a/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs +++ b/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs @@ -12,9 +12,11 @@ internal class OrganizationCipher : ICustomization { fixture.Customize(composer => composer .With(c => c.OrganizationId, OrganizationId ?? Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.UserId)); fixture.Customize(composer => composer .With(c => c.OrganizationId, Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.UserId)); } } @@ -26,9 +28,11 @@ internal class UserCipher : ICustomization { fixture.Customize(composer => composer .With(c => c.UserId, UserId ?? Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.OrganizationId)); fixture.Customize(composer => composer .With(c => c.UserId, Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.OrganizationId)); } } diff --git a/test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs b/test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs new file mode 100644 index 0000000000..624db7941d --- /dev/null +++ b/test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Test.AutoFixture.CipherFixtures; +using Bit.Core.Vault.Commands; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Commands; + +[UserCipherCustomize] +[SutProviderCustomize] +public class ArchiveCiphersCommandTest +{ + [Theory] + [BitAutoData(true, false, 1, 1, 1)] + [BitAutoData(false, false, 1, 0, 1)] + [BitAutoData(false, true, 1, 0, 1)] + [BitAutoData(true, true, 1, 0, 1)] + public async Task ArchiveAsync_Works( + bool isEditable, bool hasOrganizationId, + int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls, + SutProvider sutProvider, CipherDetails cipher, User user) + { + cipher.Edit = isEditable; + cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null; + + var cipherList = new List { cipher }; + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id).Returns(cipherList); + + // Act + await sutProvider.Sut.ArchiveManyAsync([cipher.Id], user.Id); + + // Assert + await sutProvider.GetDependency().Received(cipherRepoCalls).ArchiveAsync( + Arg.Is>(ids => ids.Count() == resultCountFromQuery + && ids.Count() >= 1 + ? true + : ids.All(id => cipherList.Contains(cipher))), + user.Id); + await sutProvider.GetDependency().Received(pushNotificationsCalls) + .PushSyncCiphersAsync(user.Id); + } +} diff --git a/test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs b/test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs new file mode 100644 index 0000000000..0a41f1cce8 --- /dev/null +++ b/test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Test.AutoFixture.CipherFixtures; +using Bit.Core.Vault.Commands; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Commands; + +[UserCipherCustomize] +[SutProviderCustomize] +public class UnarchiveCiphersCommandTest +{ + [Theory] + [BitAutoData(true, false, 1, 1, 1)] + [BitAutoData(false, false, 1, 0, 1)] + [BitAutoData(false, true, 1, 0, 1)] + [BitAutoData(true, true, 1, 1, 1)] + public async Task UnarchiveAsync_Works( + bool isEditable, bool hasOrganizationId, + int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls, + SutProvider sutProvider, CipherDetails cipher, User user) + { + cipher.Edit = isEditable; + cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null; + + var cipherList = new List { cipher }; + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id).Returns(cipherList); + + // Act + await sutProvider.Sut.UnarchiveManyAsync([cipher.Id], user.Id); + + // Assert + await sutProvider.GetDependency().Received(cipherRepoCalls).UnarchiveAsync( + Arg.Is>(ids => ids.Count() == resultCountFromQuery + && ids.Count() >= 1 + ? true + : ids.All(id => cipherList.Contains(cipher))), + user.Id); + await sutProvider.GetDependency().Received(pushNotificationsCalls) + .PushSyncCiphersAsync(user.Id); + } +} diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index 2a31398a02..ef28d776d7 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1211,4 +1211,34 @@ public class CipherRepositoryTests Assert.Null(deletedCipher2); } + + [DatabaseTheory, DatabaseData] + public async Task ArchiveAsync_Works( + ICipherRepository sutRepository, + IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Ciphers + var cipher = await sutRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + Data = "", + UserId = user.Id + }); + + // Act + await sutRepository.ArchiveAsync(new List { cipher.Id }, user.Id); + + // Assert + var archivedCipher = await sutRepository.GetByIdAsync(cipher.Id, user.Id); + Assert.NotNull(archivedCipher); + Assert.NotNull(archivedCipher.ArchivedDate); + } } diff --git a/util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql b/util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql new file mode 100644 index 0000000000..e1ef1a2399 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql @@ -0,0 +1,576 @@ +-- Add the ArchivedDate column to the Cipher table +IF COL_LENGTH('[dbo].[Cipher]', 'ArchivedDate') IS NULL +BEGIN + ALTER TABLE [dbo].[Cipher] + ADD [ArchivedDate] DATETIME2(7) NULL + END +GO + +-- Recreate CipherView +CREATE OR ALTER VIEW [dbo].[CipherView] +AS +SELECT + * +FROM + [dbo].[Cipher] + GO + +-- Alter CipherDetails function +CREATE OR ALTER FUNCTION [dbo].[CipherDetails](@UserId UNIQUEIDENTIFIER) +RETURNS TABLE +AS RETURN +SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[CreationDate], + C.[RevisionDate], + CASE + WHEN + @UserId IS NULL + OR C.[Favorites] IS NULL + OR JSON_VALUE(C.[Favorites], CONCAT('$."', @UserId, '"')) IS NULL + THEN 0 + ELSE 1 + END [Favorite], + CASE + WHEN + @UserId IS NULL + OR C.[Folders] IS NULL + THEN NULL + ELSE TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(C.[Folders], CONCAT('$."', @UserId, '"'))) +END [FolderId], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + C.[ArchivedDate] +FROM + [dbo].[Cipher] C +GO + + +-- Manually refresh UserCipherDetails +IF OBJECT_ID('[dbo].[UserCipherDetails]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[UserCipherDetails]'; +END +GO + + +-- Update sprocs +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Cipher] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Favorites], + [Folders], + [CreationDate], + [RevisionDate], + [DeletedDate], + [Reprompt], + [Key], + [ArchivedDate] + ) + VALUES + ( + @Id, + CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + @OrganizationId, + @Type, + @Data, + @Favorites, + @Folders, + @CreationDate, + @RevisionDate, + @DeletedDate, + @Reprompt, + @Key, + @ArchivedDate + ) + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Cipher] + SET + [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Data] = @Data, + [Favorites] = @Favorites, + [Folders] = @Folders, + [Attachments] = @Attachments, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Reprompt] = @Reprompt, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Create] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"') + DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey) + + INSERT INTO [dbo].[Cipher] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Favorites], + [Folders], + [CreationDate], + [RevisionDate], + [DeletedDate], + [Reprompt], + [Key], + [ArchivedDate] + ) + VALUES + ( + @Id, + CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + @OrganizationId, + @Type, + @Data, + CASE WHEN @Favorite = 1 THEN CONCAT('{', @UserIdKey, ':true}') ELSE NULL END, + CASE WHEN @FolderId IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') ELSE NULL END, + @CreationDate, + @RevisionDate, + @DeletedDate, + @Reprompt, + @Key, + @ArchivedDate + ) + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(2), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"') + DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey) + + UPDATE + [dbo].[Cipher] + SET + [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Data] = @Data, + [Folders] = + CASE + WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN + CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') + WHEN @FolderId IS NOT NULL THEN + JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50))) + ELSE + JSON_MODIFY([Folders], @UserIdPath, NULL) + END, + [Favorites] = + CASE + WHEN @Favorite = 1 AND [Favorites] IS NULL THEN + CONCAT('{', @UserIdKey, ':true}') + WHEN @Favorite = 1 THEN + JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT)) + ELSE + JSON_MODIFY([Favorites], @UserIdPath, NULL) + END, + [Attachments] = @Attachments, + [Reprompt] = @Reprompt, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage, + @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + +BEGIN TRANSACTION Cipher_UpdateWithCollections + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + IF @UpdateCollectionsSuccess < 0 +BEGIN + COMMIT TRANSACTION Cipher_UpdateWithCollections + SELECT -1 -- -1 = Failure + RETURN +END + +UPDATE + [dbo].[Cipher] +SET + [UserId] = NULL, + [OrganizationId] = @OrganizationId, + [Data] = @Data, + [Attachments] = @Attachments, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate +-- No need to update CreationDate, Favorites, Folders, or Type since that data will not change +WHERE + [Id] = @Id + + COMMIT TRANSACTION Cipher_UpdateWithCollections + + IF @Attachments IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_UpdateStorage] @UserId + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + + SELECT 0 -- 0 = Success +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Archive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Unarchive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NOT NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = NULL, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END +GO + +-- Update User Cipher Details With Archive + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_ReadByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Attachments], + [CreationDate], + [RevisionDate], + [Favorite], + [FolderId], + [DeletedDate], + [Reprompt], + [Key], + [OrganizationUseTotp], + [ArchivedDate], + MAX ([Edit]) AS [Edit], + MAX ([ViewPassword]) AS [ViewPassword], + MAX ([Manage]) AS [Manage] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Id] = @Id + GROUP BY + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Attachments], + [CreationDate], + [RevisionDate], + [Favorite], + [FolderId], + [DeletedDate], + [Reprompt], + [Key], + [OrganizationUseTotp], + [ArchivedDate] +END diff --git a/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs new file mode 100644 index 0000000000..a9b8277856 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs @@ -0,0 +1,3020 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829152208_AddArchivedDateToCipher")] + partial class AddArchivedDateToCipher + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasColumnType("longtext"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs new file mode 100644 index 0000000000..bd2a159a39 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddArchivedDateToCipher : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedDate", + table: "Cipher", + type: "datetime(6)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedDate", + table: "Cipher"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 69301d7e54..beab31cebf 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2184,6 +2184,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Id") .HasColumnType("char(36)"); + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + b.Property("Attachments") .HasColumnType("longtext"); diff --git a/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs new file mode 100644 index 0000000000..97d551603a --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs @@ -0,0 +1,3026 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829152204_AddArchivedDateToCipher")] + partial class AddArchivedDateToCipher + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs new file mode 100644 index 0000000000..f52d7601a4 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddArchivedDateToCipher : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedDate", + table: "Cipher", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedDate", + table: "Cipher"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index b0e34084e8..3d66e2c035 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2190,6 +2190,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + b.Property("Attachments") .HasColumnType("text"); diff --git a/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs new file mode 100644 index 0000000000..23b6c6c752 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs @@ -0,0 +1,3009 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829152213_AddArchivedDateToCipher")] + partial class AddArchivedDateToCipher + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedDate") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs new file mode 100644 index 0000000000..38fec3fe81 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddArchivedDateToCipher : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedDate", + table: "Cipher", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedDate", + table: "Cipher"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index caee8fef2a..d091cb4830 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2173,6 +2173,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Id") .HasColumnType("TEXT"); + b.Property("ArchivedDate") + .HasColumnType("TEXT"); + b.Property("Attachments") .HasColumnType("TEXT"); From 854abb0993ccc6be05b400f269674920adf4586d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 12 Sep 2025 13:44:19 -0400 Subject: [PATCH 71/85] [PM-23845] Update cache service to handle concurrency (#6170) --- .../IApplicationCacheServiceBusMessaging.cs | 10 + ...VCurrentInMemoryApplicationCacheService.cs | 19 + .../IVNextInMemoryApplicationCacheService.cs | 19 + .../NoOpApplicationCacheMessaging.cs | 21 + .../ServiceBusApplicationCacheMessaging.cs | 63 ++ .../VNextInMemoryApplicationCacheService.cs | 137 +++++ src/Core/Constants.cs | 2 + .../ApplicationCacheHostedService.cs | 5 +- .../FeatureRoutedCacheService.cs | 152 +++++ .../InMemoryApplicationCacheService.cs | 3 +- ...MemoryServiceBusApplicationCacheService.cs | 5 +- src/Events/Startup.cs | 11 +- .../Utilities/ServiceCollectionExtensions.cs | 11 +- ...extInMemoryApplicationCacheServiceTests.cs | 403 +++++++++++++ .../FeatureRoutedCacheServiceTests.cs | 541 ++++++++++++++++++ 15 files changed, 1392 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs create mode 100644 src/Core/Services/Implementations/FeatureRoutedCacheService.cs create mode 100644 test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs create mode 100644 test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs diff --git a/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs new file mode 100644 index 0000000000..d0cecfb10d --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IApplicationCacheServiceBusMessaging +{ + Task NotifyOrganizationAbilityUpsertedAsync(Organization organization); + Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId); + Task NotifyProviderAbilityDeletedAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..e8152b1e98 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVCurrentInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..57109ba6a7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVNextInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs new file mode 100644 index 0000000000..36a380a850 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs @@ -0,0 +1,21 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class NoOpApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + public Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + return Task.CompletedTask; + } + + public Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + return Task.CompletedTask; + } + + public Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + return Task.CompletedTask; + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs new file mode 100644 index 0000000000..f267871da7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs @@ -0,0 +1,63 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class ServiceBusApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + private readonly ServiceBusSender _topicMessageSender; + private readonly string _subName; + + public ServiceBusApplicationCacheMessaging( + GlobalSettings globalSettings) + { + _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); + var serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = serviceBusClient.CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); + } + + public async Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.UpsertOrganizationAbility }, + { "id", organization.Id }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteOrganizationAbility }, + { "id", organizationId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteProviderAbility }, + { "id", providerId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..409074e3b2 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class VNextInMemoryApplicationCacheService( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + TimeProvider timeProvider) : IVNextInMemoryApplicationCacheService +{ + private ConcurrentDictionary _orgAbilities = new(); + private readonly SemaphoreSlim _orgInitLock = new(1, 1); + private DateTimeOffset _lastOrgAbilityRefresh = DateTimeOffset.MinValue; + + private ConcurrentDictionary _providerAbilities = new(); + private readonly SemaphoreSlim _providerInitLock = new(1, 1); + private DateTimeOffset _lastProviderAbilityRefresh = DateTimeOffset.MinValue; + + private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(10); + + public virtual async Task> GetOrganizationAbilitiesAsync() + { + await InitOrganizationAbilitiesAsync(); + return _orgAbilities; + } + + public async Task GetOrganizationAbilityAsync(Guid organizationId) + { + (await GetOrganizationAbilitiesAsync()) + .TryGetValue(organizationId, out var organizationAbility); + return organizationAbility; + } + + public virtual async Task> GetProviderAbilitiesAsync() + { + await InitProviderAbilitiesAsync(); + return _providerAbilities; + } + + public virtual async Task UpsertProviderAbilityAsync(Provider provider) + { + await InitProviderAbilitiesAsync(); + _providerAbilities.AddOrUpdate( + provider.Id, + static (_, provider) => new ProviderAbility(provider), + static (_, _, provider) => new ProviderAbility(provider), + provider); + } + + public virtual async Task UpsertOrganizationAbilityAsync(Organization organization) + { + await InitOrganizationAbilitiesAsync(); + + _orgAbilities.AddOrUpdate( + organization.Id, + static (_, organization) => new OrganizationAbility(organization), + static (_, _, organization) => new OrganizationAbility(organization), + organization); + } + + public virtual Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + _orgAbilities.TryRemove(organizationId, out _); + return Task.CompletedTask; + } + + public virtual Task DeleteProviderAbilityAsync(Guid providerId) + { + _providerAbilities.TryRemove(providerId, out _); + return Task.CompletedTask; + } + + private async Task InitOrganizationAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _orgAbilities = dict, + () => _lastOrgAbilityRefresh, + dt => _lastOrgAbilityRefresh = dt, + _orgInitLock, + async () => await organizationRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + private async Task InitProviderAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _providerAbilities = dict, + () => _lastProviderAbilityRefresh, + dateTime => _lastProviderAbilityRefresh = dateTime, + _providerInitLock, + async () => await providerRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + + private async Task InitAbilitiesAsync( + Action> setCache, + Func getLastRefresh, + Action setLastRefresh, + SemaphoreSlim @lock, + Func>> fetchFunc, + TimeSpan refreshInterval, + Func getId) + { + if (SkipRefresh()) + { + return; + } + + await @lock.WaitAsync(); + try + { + if (SkipRefresh()) + { + return; + } + + var sources = await fetchFunc(); + var abilities = new ConcurrentDictionary( + sources.ToDictionary(getId)); + setCache(abilities); + setLastRefresh(timeProvider.GetUtcNow()); + } + finally + { + @lock.Release(); + } + + bool SkipRefresh() + { + return timeProvider.GetUtcNow() - getLastRefresh() <= refreshInterval; + } + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ed9ee02dad..17dae1255c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,6 +131,8 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; + public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; + public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; diff --git a/src/Core/HostedServices/ApplicationCacheHostedService.cs b/src/Core/HostedServices/ApplicationCacheHostedService.cs index ca2744bd10..655a713764 100644 --- a/src/Core/HostedServices/ApplicationCacheHostedService.cs +++ b/src/Core/HostedServices/ApplicationCacheHostedService.cs @@ -3,6 +3,7 @@ using Azure.Messaging.ServiceBus.Administration; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Hosting; @@ -14,7 +15,7 @@ namespace Bit.Core.HostedServices; public class ApplicationCacheHostedService : IHostedService, IDisposable { - private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService; + private readonly FeatureRoutedCacheService? _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; protected readonly ILogger _logger; private readonly ServiceBusClient _serviceBusClient; @@ -34,7 +35,7 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable { _topicName = globalSettings.ServiceBus.ApplicationCacheTopicName; _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _applicationCacheService = applicationCacheService as InMemoryServiceBusApplicationCacheService; + _applicationCacheService = applicationCacheService as FeatureRoutedCacheService; _organizationRepository = organizationRepository; _logger = logger; _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); diff --git a/src/Core/Services/Implementations/FeatureRoutedCacheService.cs b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs new file mode 100644 index 0000000000..b6294a28f8 --- /dev/null +++ b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs @@ -0,0 +1,152 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services.Implementations; + +/// +/// A feature-flagged routing service for application caching that bridges the gap between +/// scoped dependency injection (IFeatureService) and singleton services (cache implementations). +/// This service allows dynamic routing between IVCurrentInMemoryApplicationCacheService and +/// IVNextInMemoryApplicationCacheService based on the PM23845_VNextApplicationCache feature flag. +/// +/// +/// This service is necessary because: +/// - IFeatureService is registered as Scoped in the DI container +/// - IVNextInMemoryApplicationCacheService and IVCurrentInMemoryApplicationCacheService are registered as Singleton +/// - We need to evaluate feature flags at request time while maintaining singleton cache behavior +/// +/// The service acts as a scoped proxy that can access the scoped IFeatureService while +/// delegating actual cache operations to the appropriate singleton implementation. +/// +public class FeatureRoutedCacheService( + IFeatureService featureService, + IVNextInMemoryApplicationCacheService vNextInMemoryApplicationCacheService, + IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService, + IApplicationCacheServiceBusMessaging serviceBusMessaging) + : IApplicationCacheService +{ + public async Task> GetOrganizationAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + return await inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + public async Task GetOrganizationAbilityAsync(Guid orgId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + return await inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + + public async Task> GetProviderAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + return await inMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + + public async Task UpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + await serviceBusMessaging.NotifyOrganizationAbilityUpsertedAsync(organization); + } + else + { + await inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + + public async Task UpsertProviderAbilityAsync(Provider provider) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + else + { + await inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + } + + public async Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + await serviceBusMessaging.NotifyOrganizationAbilityDeletedAsync(organizationId); + } + else + { + await inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + } + + public async Task DeleteProviderAbilityAsync(Guid providerId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + await serviceBusMessaging.NotifyProviderAbilityDeletedAsync(providerId); + } + else + { + await inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + } + + } + + public async Task BaseUpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } + + public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } +} diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index d1bece56c1..4062162701 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -10,7 +11,7 @@ using Bit.Core.Repositories; namespace Bit.Core.Services; -public class InMemoryApplicationCacheService : IApplicationCacheService +public class InMemoryApplicationCacheService : IVCurrentInMemoryApplicationCacheService { private readonly IOrganizationRepository _organizationRepository; private readonly IProviderRepository _providerRepository; diff --git a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs index da70ccd2fd..b856bfa749 100644 --- a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs @@ -8,9 +8,8 @@ using Bit.Core.Utilities; namespace Bit.Core.Services; -public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService, IApplicationCacheService +public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService { - private readonly ServiceBusClient _serviceBusClient; private readonly ServiceBusSender _topicMessageSender; private readonly string _subName; @@ -21,7 +20,7 @@ public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCach : base(organizationRepository, providerRepository) { _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString).CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); } diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index fdeaad04b2..cfe177aa2c 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,7 +1,9 @@ using System.Globalization; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; @@ -52,13 +54,18 @@ public class Startup // Services var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName); + services.AddScoped(); + services.AddSingleton(); + if (usingServiceBusAppCache) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } services.AddEventWriteServices(globalSettings); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 592f7c84c3..d87f9ab97f 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -41,6 +42,7 @@ using Bit.Core.Resources; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; @@ -247,14 +249,19 @@ public static class ServiceCollectionExtensions services.AddOptionality(); services.AddTokenizers(); + services.AddSingleton(); + services.AddScoped(); + if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName)) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } var awsConfigured = CoreHelpers.SettingHasValue(globalSettings.Amazon?.AccessKeySecret); diff --git a/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs b/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs new file mode 100644 index 0000000000..afd3dccda3 --- /dev/null +++ b/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs @@ -0,0 +1,403 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.AbilitiesCache; + +[SutProviderCustomize] +public class VNextInMemoryApplicationCacheServiceTests + +{ + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_FirstCall_LoadsFromRepository( + ICollection organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.IsType>(result); + Assert.Equal(organizationAbilities.Count, result.Count); + foreach (var ability in organizationAbilities) + { + Assert.True(result.TryGetValue(ability.Id, out var actualAbility)); + Assert.Equal(ability, actualAbility); + } + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_SecondCall_UsesCachedValue( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_ExistingId_ReturnsAbility( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = organizationAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(targetAbility.Id); + + // Assert + Assert.Equal(targetAbility, result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_NonExistingId_ReturnsNull( + List organizationAbilities, + Guid nonExistingId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(nonExistingId); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_FirstCall_LoadsFromRepository( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.IsType>(result); + Assert.Equal(providerAbilities.Count, result.Count); + foreach (var ability in providerAbilities) + { + Assert.True(result.TryGetValue(ability.Id, out var actualAbility)); + Assert.Equal(ability, actualAbility); + } + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_SecondCall_UsesCachedValue( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + // Act + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_NewOrganization_AddsToCache( + Organization organization, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.True(result.ContainsKey(organization.Id)); + Assert.Equal(organization.Id, result[organization.Id].Id); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_ExistingOrganization_UpdatesCache( + Organization organization, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + existingAbilities.Add(new OrganizationAbility { Id = organization.Id }); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.True(result.ContainsKey(organization.Id)); + Assert.Equal(organization.Id, result[organization.Id].Id); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_NewProvider_AddsToCache( + Provider provider, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + Assert.True(result.ContainsKey(provider.Id)); + Assert.Equal(provider.Id, result[provider.Id].Id); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_ExistingId_RemovesFromCache( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = organizationAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(targetAbility.Id); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.False(result.ContainsKey(targetAbility.Id)); + } + + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_ExistingId_RemovesFromCache( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = providerAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(targetAbility.Id); + + // Assert + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + Assert.False(result.ContainsKey(targetAbility.Id)); + } + + [Theory, BitAutoData] + public async Task ConcurrentAccess_GetOrganizationAbilities_ThreadSafe( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + var results = new ConcurrentBag>(); + + const int iterationCount = 100; + + + // Act + await Parallel.ForEachAsync( + Enumerable.Range(0, iterationCount), + async (_, _) => + { + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + results.Add(result); + }); + + // Assert + var firstCall = results.First(); + Assert.Equal(iterationCount, results.Count); + Assert.All(results, result => Assert.Same(firstCall, result)); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_AfterRefreshInterval_RefreshesFromRepository( + List organizationAbilities, + List updatedAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities, updatedAbilities); + + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + const int pastIntervalInMinutes = 11; + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.NotSame(firstCall, secondCall); + Assert.Equal(updatedAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(2).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_AfterRefreshInterval_RefreshesFromRepository( + List providerAbilities, + List updatedAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities, updatedAbilities); + + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + const int pastIntervalMinutes = 15; + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.NotSame(firstCall, secondCall); + Assert.Equal(updatedAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(2).GetManyAbilitiesAsync(); + } + + public static IEnumerable WhenCacheIsWithinIntervalTestCases => + [ + [5, 1], + [10, 1], + ]; + + [Theory] + [BitMemberAutoData(nameof(WhenCacheIsWithinIntervalTestCases))] + public async Task GetOrganizationAbilitiesAsync_WhenCacheIsWithinInterval( + int pastIntervalInMinutes, + int expectCacheHit, + List organizationAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + Assert.Equal(organizationAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(expectCacheHit).GetManyAbilitiesAsync(); + } + + [Theory] + [BitMemberAutoData(nameof(WhenCacheIsWithinIntervalTestCases))] + public async Task GetProviderAbilitiesAsync_WhenCacheIsWithinInterval( + int pastIntervalInMinutes, + int expectCacheHit, + List providerAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + Assert.Equal(providerAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(expectCacheHit).GetManyAbilitiesAsync(); + } + + private static void SimulateTimeLapseAfterFirstCall(SutProvider sutProvider, int pastIntervalInMinutes) => + sutProvider + .GetDependency() + .Advance(TimeSpan.FromMinutes(pastIntervalInMinutes)); + +} diff --git a/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs new file mode 100644 index 0000000000..3309f2bf23 --- /dev/null +++ b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs @@ -0,0 +1,541 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Services.Implementations; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services.Implementations; + +[SutProviderCustomize] +public class FeatureRoutedCacheServiceTests +{ + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + Guid orgId, + OrganizationAbility expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(orgId); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilityAsync(orgId); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilityAsync(orgId); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + Guid orgId, + OrganizationAbility expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(orgId); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilityAsync(orgId); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilityAsync(orgId); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetProviderAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetProviderAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Provider provider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertProviderAbilityAsync(provider); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertProviderAbilityAsync(provider); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Provider provider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertProviderAbilityAsync(provider); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertProviderAbilityAsync(provider); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(providerId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteProviderAbilityAsync(providerId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteProviderAbilityAsync(providerId); + } + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(providerId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteProviderAbilityAsync(providerId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteProviderAbilityAsync(providerId); + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.BaseUpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertOrganizationAbilityAsync(organization); + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsServiceBusCache( + Organization organization) + { + // Arrange + var featureService = Substitute.For(); + + var currentCacheService = CreateCurrentCacheMockService(); + + featureService + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + var sutProvider = Substitute.For( + featureService, + Substitute.For(), + currentCacheService, + Substitute.For()); + + // Act + await sutProvider.BaseUpsertOrganizationAbilityAsync(organization); + + // Assert + await currentCacheService + .Received(1) + .BaseUpsertOrganizationAbilityAsync(organization); + } + + /// + /// Our SUT is using a method that is not part of the IVCurrentInMemoryApplicationCacheService, + /// so AutoFixture’s auto-created mock won’t work. + /// + /// + private static InMemoryServiceBusApplicationCacheService CreateCurrentCacheMockService() + { + var currentCacheService = Substitute.For( + Substitute.For(), + Substitute.For(), + new GlobalSettings + { + ProjectName = "BitwardenTest", + ServiceBus = new GlobalSettings.ServiceBusSettings + { + ConnectionString = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ApplicationCacheTopicName = "test-topic", + ApplicationCacheSubscriptionName = "test-subscription" + } + }); + return currentCacheService; + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_AndServiceIsNotServiceBusCache_ThrowsException( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.BaseUpsertOrganizationAbilityAsync(organization)); + + // Assert + Assert.Equal( + ExpectedErrorMessage, + ex.Message); + } + + private static string ExpectedErrorMessage + { + get => "Expected inMemoryApplicationCacheService to be of type InMemoryServiceBusApplicationCacheService"; + } + + [Theory, BitAutoData] + public async Task BaseDeleteOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.BaseDeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task BaseDeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsServiceBusCache( + Guid organizationId) + { + // Arrange + var featureService = Substitute.For(); + + var currentCacheService = CreateCurrentCacheMockService(); + + featureService + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + var sutProvider = Substitute.For( + featureService, + Substitute.For(), + currentCacheService, + Substitute.For()); + + // Act + await sutProvider.BaseDeleteOrganizationAbilityAsync(organizationId); + + // Assert + await currentCacheService + .Received(1) + .BaseDeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task + BaseDeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_AndServiceIsNotServiceBusCache_ThrowsException( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + var ex = await Assert.ThrowsAsync(() => + sutProvider.Sut.BaseDeleteOrganizationAbilityAsync(organizationId)); + + // Assert + Assert.Equal( + ExpectedErrorMessage, + ex.Message); + } +} From 6ade09312fcca883d199dc2e7e3049c0f76f3f54 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:49:40 -0700 Subject: [PATCH 72/85] [PM-21044] - optimize security task ReadByUserIdStatus (#5779) * optimize security task ReadByUserIdStatus * fix AccessibleCiphers query * fix error * add migrator file * fix migration * update sproc * mirror sprocs * revert change to sproc * add indexes. update filename. add GO statement * move index declarations to appropriate files * add missing GO statement * select view. add existance checks for index * update indexes * revert changes * rename file * update security task * update sproc * update script file * bump migration date * add filtered index. update statistics, update description with perf metics * rename file * reordering * remove update statistics * remove update statistics * add missing index * fix sproc * update timestamp * improve sproc with de-dupe and views * fix syntax error * add missing inner join * sync up index * fix indentation * update file timestamp * remove unnecessary indexes. update sql to match guidelines. * add comment for status * add comment for status --- src/Sql/dbo/Tables/CollectionCipher.sql | 1 + src/Sql/dbo/Tables/OrganizationUser.sql | 7 + .../SecurityTask_ReadByUserIdStatus.sql | 123 +++++++++++------- src/Sql/dbo/Vault/Tables/SecurityTask.sql | 3 + .../2025-09-03_00_ImproveSecurityTask.sql | 101 ++++++++++++++ 5 files changed, 189 insertions(+), 46 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql diff --git a/src/Sql/dbo/Tables/CollectionCipher.sql b/src/Sql/dbo/Tables/CollectionCipher.sql index f661cb6fbd..0891b7bc42 100644 --- a/src/Sql/dbo/Tables/CollectionCipher.sql +++ b/src/Sql/dbo/Tables/CollectionCipher.sql @@ -11,3 +11,4 @@ GO CREATE NONCLUSTERED INDEX [IX_CollectionCipher_CipherId] ON [dbo].[CollectionCipher]([CipherId] ASC); +GO diff --git a/src/Sql/dbo/Tables/OrganizationUser.sql b/src/Sql/dbo/Tables/OrganizationUser.sql index 51ed2115bc..a9f228dc3d 100644 --- a/src/Sql/dbo/Tables/OrganizationUser.sql +++ b/src/Sql/dbo/Tables/OrganizationUser.sql @@ -35,3 +35,10 @@ CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId] INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate], [RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager]); GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_UserId_Status_Filtered] + ON [dbo].[OrganizationUser] ([UserId]) + INCLUDE ([Id], [OrganizationId]) + WHERE [Status] = 2; -- Confirmed + +GO diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql index 2a4ecdb4c1..2614135c54 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql @@ -1,56 +1,87 @@ CREATE PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus] - @UserId UNIQUEIDENTIFIER, - @Status TINYINT = NULL + @UserId [UNIQUEIDENTIFIER], + @Status [TINYINT] = NULL AS BEGIN - SET NOCOUNT ON + SET NOCOUNT ON; + WITH [OrganizationAccess] AS ( + SELECT + [OU].[OrganizationId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + ), + [UserCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[CollectionUser] [CU] + ON [CU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CU].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + AND [CU].[ReadOnly] = 0 + ), + [GroupCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[GroupUser] [GU] + ON [GU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionGroup] [CG] + ON [CG].[GroupId] = [GU].[GroupId] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CG].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [CG].[ReadOnly] = 0 + ), + [AccessibleCiphers] AS ( + SELECT + [CipherId] FROM [UserCollectionAccess] + UNION + SELECT + [CipherId] FROM [GroupCollectionAccess] + ) SELECT - ST.Id, - ST.OrganizationId, - ST.CipherId, - ST.Type, - ST.Status, - ST.CreationDate, - ST.RevisionDate + [ST].[Id], + [ST].[OrganizationId], + [ST].[CipherId], + [ST].[Type], + [ST].[Status], + [ST].[CreationDate], + [ST].[RevisionDate] FROM - [dbo].[SecurityTaskView] ST - INNER JOIN - [dbo].[OrganizationUserView] OU ON OU.[OrganizationId] = ST.[OrganizationId] - INNER JOIN - [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId] - LEFT JOIN - [dbo].[CipherView] C ON C.[Id] = ST.[CipherId] - LEFT JOIN - [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id] AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] IS NULL AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[CollectionGroup] CG ON CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = CC.[CollectionId] + [dbo].[SecurityTaskView] [ST] + INNER JOIN [OrganizationAccess] [OA] + ON [ST].[OrganizationId] = [OA].[OrganizationId] WHERE - OU.[UserId] = @UserId - AND OU.[Status] = 2 -- Ensure user is confirmed - AND O.[Enabled] = 1 + (@Status IS NULL OR [ST].[Status] = @Status) AND ( - ST.[CipherId] IS NULL - OR ( - C.[Id] IS NOT NULL - AND ( - CU.[ReadOnly] = 0 - OR CG.[ReadOnly] = 0 - ) - ) + [ST].[CipherId] IS NULL + OR EXISTS ( + SELECT 1 + FROM [AccessibleCiphers] [AC] + WHERE [AC].[CipherId] = [ST].[CipherId] + ) ) - AND ST.[Status] = COALESCE(@Status, ST.[Status]) - GROUP BY - ST.Id, - ST.OrganizationId, - ST.CipherId, - ST.Type, - ST.Status, - ST.CreationDate, - ST.RevisionDate - ORDER BY ST.[CreationDate] DESC + ORDER BY + [ST].[CreationDate] DESC + OPTION (RECOMPILE); END diff --git a/src/Sql/dbo/Vault/Tables/SecurityTask.sql b/src/Sql/dbo/Vault/Tables/SecurityTask.sql index a00dcede9c..dbf9827a63 100644 --- a/src/Sql/dbo/Vault/Tables/SecurityTask.sql +++ b/src/Sql/dbo/Vault/Tables/SecurityTask.sql @@ -19,3 +19,6 @@ CREATE NONCLUSTERED INDEX [IX_SecurityTask_CipherId] GO CREATE NONCLUSTERED INDEX [IX_SecurityTask_OrganizationId] ON [dbo].[SecurityTask]([OrganizationId] ASC) WHERE OrganizationId IS NOT NULL; + +GO + diff --git a/util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql b/util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql new file mode 100644 index 0000000000..743caf4672 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql @@ -0,0 +1,101 @@ +CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus] + @UserId [UNIQUEIDENTIFIER], + @Status [TINYINT] = NULL +AS +BEGIN + SET NOCOUNT ON; + + WITH [OrganizationAccess] AS ( + SELECT + [OU].[OrganizationId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + ), + [UserCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[CollectionUser] [CU] + ON [CU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CU].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + AND [CU].[ReadOnly] = 0 + ), + [GroupCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[GroupUser] [GU] + ON [GU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionGroup] [CG] + ON [CG].[GroupId] = [GU].[GroupId] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CG].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [CG].[ReadOnly] = 0 + ), + [AccessibleCiphers] AS ( + SELECT + [CipherId] FROM [UserCollectionAccess] + UNION + SELECT + [CipherId] FROM [GroupCollectionAccess] + ) + SELECT + [ST].[Id], + [ST].[OrganizationId], + [ST].[CipherId], + [ST].[Type], + [ST].[Status], + [ST].[CreationDate], + [ST].[RevisionDate] + FROM + [dbo].[SecurityTaskView] [ST] + INNER JOIN [OrganizationAccess] [OA] + ON [ST].[OrganizationId] = [OA].[OrganizationId] + WHERE + (@Status IS NULL OR [ST].[Status] = @Status) + AND ( + [ST].[CipherId] IS NULL + OR EXISTS ( + SELECT 1 + FROM [AccessibleCiphers] [AC] + WHERE [AC].[CipherId] = [ST].[CipherId] + ) + ) + ORDER BY + [ST].[CreationDate] DESC + OPTION (RECOMPILE); +END +GO + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.OrganizationUser') + AND name = 'IX_OrganizationUser_UserId_Status_Filtered' +) +BEGIN +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_UserId_Status_Filtered] + ON [dbo].[OrganizationUser] ([UserId]) + INCLUDE ([Id], [OrganizationId]) + WHERE [Status] = 2; -- Confirmed +END From b4a0555a72a6b538ec04e0598a9543ce33b2b6a0 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 15 Sep 2025 15:02:40 +0200 Subject: [PATCH 73/85] Change swagger docs to refer to main (#6337) --- src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs index cbad1e9736..fba8b17078 100644 --- a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -17,8 +17,6 @@ namespace Bit.SharedWeb.Swagger; /// public class SourceFileLineOperationFilter : IOperationFilter { - private static readonly string _gitCommit = GitCommitDocumentFilter.GitCommit ?? "main"; - public void Apply(OpenApiOperation operation, OperationFilterContext context) { @@ -27,7 +25,7 @@ public class SourceFileLineOperationFilter : IOperationFilter { // Add the information with a link to the source file at the end of the operation description operation.Description += - $"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/{_gitCommit}/{fileName}#L{lineNumber}`]"; + $"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/main/{fileName}#L{lineNumber}`]"; // Also add the information as extensions, so other tools can use it in the future operation.Extensions.Add("x-source-file", new OpenApiString(fileName)); From b249c4e4d7ddaf0646794ca8f5b40fe1fb9566ad Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 15 Sep 2025 08:22:39 -0500 Subject: [PATCH 74/85] [PM-23761] Auto-reply to tickets in Freskdesk with help from Onyx AI (#6315) --- src/Billing/Billing.csproj | 1 + src/Billing/BillingSettings.cs | 4 + .../Controllers/FreshdeskController.cs | 94 +++++++++++++++++++ .../Models/FreshdeskReplyRequestModel.cs | 9 ++ src/Billing/appsettings.json | 5 +- 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/Billing/Models/FreshdeskReplyRequestModel.cs diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 18c627c5de..d6eb2b4411 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 5609879eeb..32630e4a4a 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -34,6 +34,10 @@ public class BillingSettings public virtual string Region { get; set; } public virtual string UserFieldName { get; set; } public virtual string OrgFieldName { get; set; } + + public virtual bool RemoveNewlinesInReplies { get; set; } = false; + public virtual string AutoReplyGreeting { get; set; } = string.Empty; + public virtual string AutoReplySalutation { get; set; } = string.Empty; } public class OnyxSettings diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index a854d2d49f..66d4f47d92 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -11,6 +11,7 @@ using Bit.Billing.Models; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; +using Markdig; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -184,6 +185,52 @@ public class FreshdeskController : Controller return Ok(); } + [HttpPost("webhook-onyx-ai-reply")] + public async Task PostWebhookOnyxAiReply([FromQuery, Required] string key, + [FromBody, Required] FreshdeskOnyxAiWebhookModel model) + { + // NOTE: + // at this time, this endpoint is a duplicate of `webhook-onyx-ai` + // eventually, we will merge both endpoints into one webhook for Freshdesk + + // ensure that the key is from Freshdesk + if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid) + { + return new BadRequestResult(); + } + + // if there is no description, then we don't send anything to onyx + if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) + { + return Ok(); + } + + // create the onyx `answer-with-citation` request + var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId); + var onyxRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) + { + Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")), + }; + var (_, onyxJsonResponse) = await CallOnyxApi(onyxRequest); + + // the CallOnyxApi will return a null if we have an error response + if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg)) + { + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequestModel), + JsonSerializer.Serialize(onyxJsonResponse)); + + return Ok(); // return ok so we don't retry + } + + // add the reply to the ticket + await AddReplyToTicketAsync(onyxJsonResponse.Answer, model.TicketId); + + return Ok(); + } + private bool IsValidRequestFromFreshdesk(string key) { if (string.IsNullOrWhiteSpace(key) @@ -238,6 +285,53 @@ public class FreshdeskController : Controller } } + private async Task AddReplyToTicketAsync(string note, string ticketId) + { + // if there is no content, then we don't need to add a note + if (string.IsNullOrWhiteSpace(note)) + { + return; + } + + // convert note from markdown to html + var htmlNote = note; + try + { + var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + htmlNote = Markdig.Markdown.ToHtml(note, pipeline); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}", + ticketId, note); + htmlNote = note; // fallback to the original note + } + + // clear out any new lines that Freshdesk doesn't like + if (_billingSettings.FreshDesk.RemoveNewlinesInReplies) + { + htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty); + } + + var replyBody = new FreshdeskReplyRequestModel + { + Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}", + }; + + var replyRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId)) + { + Content = JsonContent.Create(replyBody), + }; + + var addReplyResponse = await CallFreshdeskApiAsync(replyRequest); + if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created) + { + _logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}", + ticketId, addReplyResponse.ToString()); + } + } + private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) { try diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs new file mode 100644 index 0000000000..3927039769 --- /dev/null +++ b/src/Billing/Models/FreshdeskReplyRequestModel.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class FreshdeskReplyRequestModel +{ + [JsonPropertyName("body")] + public required string Body { get; set; } +} diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index aae25dde0b..0074b5aafe 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -72,7 +72,10 @@ "webhookKey": "SECRET", "region": "US", "userFieldName": "cf_user", - "orgFieldName": "cf_org" + "orgFieldName": "cf_org", + "removeNewlinesInReplies": true, + "autoReplyGreeting": "Greetings,

Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:

", + "autoReplySalutation": "

If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.

Best Regards,
The Bitwarden Customer Success Team

" }, "onyx": { "apiKey": "SECRET", From 981ff51d5785e1ea8ab7751010e02fa6fdd7a968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 15 Sep 2025 16:49:46 +0200 Subject: [PATCH 75/85] Update swashbuckle to fix API docs (#6319) --- .config/dotnet-tools.json | 2 +- src/Api/Api.csproj | 2 +- src/Billing/Billing.csproj | 2 +- src/SharedWeb/SharedWeb.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 41674ccad0..227f59ad8a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "9.0.2", + "version": "9.0.4", "commands": ["swagger"] }, "dotnet-ef": { diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index d48f49626f..138549e92d 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index d6eb2b4411..e2b7447eb7 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 445b98cce0..8bffa285fc 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -7,7 +7,7 @@ - + From 0ee307a027ad534c9ced56239b8ca66b1276aa7f Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:56:33 -0400 Subject: [PATCH 76/85] [PM-25533][BEEEP] Refactor license date calculations into extensions (#6295) * Refactor license date calculations into extensions * `dotnet format` * Handling case when expirationWithoutGracePeriod is null * Removed extra UseAdminSponsoredFamilies claim --- .../Licenses/Extensions/LicenseExtensions.cs | 103 ++++++++++++------ .../OrganizationLicenseClaimsFactory.cs | 25 ++--- .../Models/OrganizationLicense.cs | 49 +-------- 3 files changed, 86 insertions(+), 91 deletions(-) diff --git a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs index f5b4499ea8..8cd3438191 100644 --- a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs +++ b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs @@ -9,75 +9,108 @@ namespace Bit.Core.Billing.Licenses.Extensions; public static class LicenseExtensions { - public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo) + public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime issued) { + if (subscriptionInfo?.Subscription == null) { - if (org.ExpirationDate.HasValue) - { - return org.ExpirationDate.Value; - } - - return DateTime.UtcNow.AddDays(7); + // Subscription isn't setup yet, so fallback to the organization's expiration date + // If there isn't an expiration date on the org, treat it as a free trial + return org.ExpirationDate ?? issued.AddDays(7); } var subscription = subscriptionInfo.Subscription; if (subscription.TrialEndDate > DateTime.UtcNow) { + // Still trialing, use trial's end date return subscription.TrialEndDate.Value; } - if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) + if (org.ExpirationDate < DateTime.UtcNow) { + // Organization is expired return org.ExpirationDate.Value; } - if (subscription.PeriodEndDate.HasValue && subscription.PeriodDuration > TimeSpan.FromDays(180)) + if (subscription.PeriodDuration > TimeSpan.FromDays(180)) { + // Annual subscription - include grace period to give the administrators time to upload a new license return subscription.PeriodEndDate - .Value - .AddDays(Bit.Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); + !.Value + .AddDays(Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); } - return org.ExpirationDate?.AddMonths(11) ?? DateTime.UtcNow.AddYears(1); + // Monthly subscription - giving an annual expiration to not burnden admins to upload fresh licenses each month + return org.ExpirationDate?.AddMonths(11) ?? issued.AddYears(1); } - public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) + public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime issued) { - if (subscriptionInfo?.Subscription == null || - subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow || - org.ExpirationDate < DateTime.UtcNow) - { - return expirationDate; - } - return subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180) || - DateTime.UtcNow - expirationDate > TimeSpan.FromDays(30) - ? DateTime.UtcNow.AddDays(30) - : expirationDate; - } - - public static DateTime CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) - { - if (subscriptionInfo?.Subscription is null) + if (subscriptionInfo?.Subscription == null) { - return expirationDate; + // Subscription isn't setup yet, so fallback to the organization's expiration date + // If there isn't an expiration date on the org, treat it as a free trial + return org.ExpirationDate ?? issued.AddDays(7); } var subscription = subscriptionInfo.Subscription; - if (subscription.TrialEndDate <= DateTime.UtcNow && - org.ExpirationDate >= DateTime.UtcNow && - subscription.PeriodEndDate.HasValue && - subscription.PeriodDuration > TimeSpan.FromDays(180)) + if (subscription.TrialEndDate > DateTime.UtcNow) { - return subscription.PeriodEndDate.Value; + // Still trialing, use trial's end date + return subscription.TrialEndDate.Value; } - return expirationDate; + if (org.ExpirationDate < DateTime.UtcNow) + { + // Organization is expired + return org.ExpirationDate.Value; + } + + if (subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + // Annual subscription - refresh every 30 days to check for plan changes, cancellations, and payment issues + return issued.AddDays(30); + } + + var expires = org.ExpirationDate?.AddMonths(11) ?? issued.AddYears(1); + + // If expiration is more than 30 days in the past, refresh in 30 days instead of using the stale date to give + // them a chance to refresh. Otherwise, uses the expiration date + return issued - expires > TimeSpan.FromDays(30) + ? issued.AddDays(30) + : expires; } + public static DateTime? CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo) + { + // It doesn't make sense that this returns null sometimes. If the expiration date doesn't include a grace period + // then we should just return the expiration date instead of null. This is currently forcing the single consumer + // to check for nulls. + + // At some point in the future, we should update this. We can't easily, though, without breaking the signatures + // since `ExpirationWithoutGracePeriod` is included on them. So for now, I'll shake my fist and then move on. + + // Only set expiration without grace period for active, non-trial, annual subscriptions + if (subscriptionInfo?.Subscription != null && + subscriptionInfo.Subscription.TrialEndDate <= DateTime.UtcNow && + org.ExpirationDate >= DateTime.UtcNow && + subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscriptionInfo.Subscription.PeriodEndDate; + } + + // Otherwise, return null. + return null; + } + + public static bool CalculateIsTrialing(this Organization org, SubscriptionInfo subscriptionInfo) => + subscriptionInfo?.Subscription is null + ? !org.ExpirationDate.HasValue + : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; + public static T GetValue(this ClaimsPrincipal principal, string claimType) { var claim = principal.FindFirst(claimType); diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 02b35583af..1e049d7f03 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Enums; -using Bit.Core.Models.Business; namespace Bit.Core.Billing.Licenses.Services.Implementations; @@ -15,11 +14,12 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory> GenerateClaims(Organization entity, LicenseContext licenseContext) { + var issued = DateTime.UtcNow; var subscriptionInfo = licenseContext.SubscriptionInfo; - var expires = entity.CalculateFreshExpirationDate(subscriptionInfo); - var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, expires); - var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo, expires); - var trial = IsTrialing(entity, subscriptionInfo); + var expires = entity.CalculateFreshExpirationDate(subscriptionInfo, issued); + var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, issued); + var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo); + var trial = entity.CalculateIsTrialing(subscriptionInfo); var claims = new List { @@ -50,10 +50,9 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory - subscriptionInfo?.Subscription is null - ? !org.ExpirationDate.HasValue - : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; } diff --git a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs index 54e20cd636..83789be2f3 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -96,50 +96,13 @@ public class OrganizationLicense : ILicense AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems; // - if (subscriptionInfo?.Subscription == null) - { - if (org.ExpirationDate.HasValue) - { - Expires = Refresh = org.ExpirationDate.Value; - Trial = false; - } - else - { - Expires = Refresh = Issued.AddDays(7); - Trial = true; - } - } - else if (subscriptionInfo.Subscription.TrialEndDate.HasValue && - subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow) - { - Expires = Refresh = subscriptionInfo.Subscription.TrialEndDate.Value; - Trial = true; - } - else - { - if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) - { - // expired - Expires = Refresh = org.ExpirationDate.Value; - } - else if (subscriptionInfo?.Subscription?.PeriodDuration != null && - subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) - { - Refresh = DateTime.UtcNow.AddDays(30); - Expires = subscriptionInfo.Subscription.PeriodEndDate?.AddDays(Core.Constants - .OrganizationSelfHostSubscriptionGracePeriodDays); - ExpirationWithoutGracePeriod = subscriptionInfo.Subscription.PeriodEndDate; - } - else - { - Expires = org.ExpirationDate.HasValue ? org.ExpirationDate.Value.AddMonths(11) : Issued.AddYears(1); - Refresh = DateTime.UtcNow - Expires > TimeSpan.FromDays(30) ? DateTime.UtcNow.AddDays(30) : Expires; - } - - Trial = false; - } - UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies; + + Expires = org.CalculateFreshExpirationDate(subscriptionInfo, Issued); + Refresh = org.CalculateFreshRefreshDate(subscriptionInfo, Issued); + ExpirationWithoutGracePeriod = org.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo); + Trial = org.CalculateIsTrialing(subscriptionInfo); + Hash = Convert.ToBase64String(ComputeHash()); Signature = Convert.ToBase64String(licenseService.SignLicense(this)); } From a173e7e2da734517f283ecd9e8ab1b62be0d2349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 15 Sep 2025 18:05:06 +0200 Subject: [PATCH 77/85] [PM-25182] Improve Swagger OperationIDs for Vault (#6240) * Improve Swagger OperationIDs for Vault * Some renames --- .../Vault/Controllers/CiphersController.cs | 132 +++++++++++++++--- .../Vault/Controllers/FoldersController.cs | 18 ++- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index db3d5fb357..6249e264c0 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -118,7 +118,6 @@ public class CiphersController : Controller return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); } - [HttpGet("{id}/full-details")] [HttpGet("{id}/details")] public async Task GetDetails(Guid id) { @@ -134,8 +133,15 @@ public class CiphersController : Controller return new CipherDetailsResponseModel(cipher, user, organizationAbilities, _globalSettings, collectionCiphers); } + [HttpGet("{id}/full-details")] + [Obsolete("This endpoint is deprecated. Use GET details method instead.")] + public async Task GetFullDetails(Guid id) + { + return await GetDetails(id); + } + [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var user = await _userService.GetUserByPrincipalAsync(User); var hasOrgs = _currentContext.Organizations.Count != 0; @@ -242,7 +248,6 @@ public class CiphersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid id, [FromBody] CipherRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -283,8 +288,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPut(Guid id, [FromBody] CipherRequestModel model) + { + return await Put(id, model); + } + [HttpPut("{id}/admin")] - [HttpPost("{id}/admin")] public async Task PutAdmin(Guid id, [FromBody] CipherRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -317,6 +328,13 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/admin")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPutAdmin(Guid id, [FromBody] CipherRequestModel model) + { + return await PutAdmin(id, model); + } + [HttpGet("organization-details")] public async Task> GetOrganizationCiphers(Guid organizationId) { @@ -691,7 +709,6 @@ public class CiphersController : Controller } [HttpPut("{id}/partial")] - [HttpPost("{id}/partial")] public async Task PutPartial(Guid id, [FromBody] CipherPartialRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -707,8 +724,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/partial")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPartial(Guid id, [FromBody] CipherPartialRequestModel model) + { + return await PutPartial(id, model); + } + [HttpPut("{id}/share")] - [HttpPost("{id}/share")] public async Task PutShare(Guid id, [FromBody] CipherShareRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -744,8 +767,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/share")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostShare(Guid id, [FromBody] CipherShareRequestModel model) + { + return await PutShare(id, model); + } + [HttpPut("{id}/collections")] - [HttpPost("{id}/collections")] public async Task PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -770,8 +799,14 @@ public class CiphersController : Controller collectionCiphers); } + [HttpPost("{id}/collections")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollections(id, model); + } + [HttpPut("{id}/collections_v2")] - [HttpPost("{id}/collections_v2")] public async Task PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -804,8 +839,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/collections_v2")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollections_vNext(id, model); + } + [HttpPut("{id}/collections-admin")] - [HttpPost("{id}/collections-admin")] public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -834,6 +875,13 @@ public class CiphersController : Controller return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); } + [HttpPost("{id}/collections-admin")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollectionsAdmin(id, model); + } + [HttpPost("bulk-collections")] public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) { @@ -895,7 +943,6 @@ public class CiphersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -908,8 +955,14 @@ public class CiphersController : Controller await _cipherService.DeleteAsync(cipher, userId); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } + [HttpDelete("{id}/admin")] - [HttpPost("{id}/delete-admin")] public async Task DeleteAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -923,8 +976,14 @@ public class CiphersController : Controller await _cipherService.DeleteAsync(cipher, userId, true); } + [HttpPost("{id}/delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAdmin(Guid id) + { + await DeleteAdmin(id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task DeleteMany([FromBody] CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -937,8 +996,14 @@ public class CiphersController : Controller await _cipherService.DeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteMany([FromBody] CipherBulkDeleteRequestModel model) + { + await DeleteMany(model); + } + [HttpDelete("admin")] - [HttpPost("delete-admin")] public async Task DeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -964,6 +1029,13 @@ public class CiphersController : Controller await _cipherService.DeleteManyAsync(cipherIds, userId, new Guid(model.OrganizationId), true); } + [HttpPost("delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel model) + { + await DeleteManyAdmin(model); + } + [HttpPut("{id}/delete")] public async Task PutDelete(Guid id) { @@ -1145,7 +1217,6 @@ public class CiphersController : Controller } [HttpPut("move")] - [HttpPost("move")] public async Task MoveMany([FromBody] CipherBulkMoveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -1158,8 +1229,14 @@ public class CiphersController : Controller string.IsNullOrWhiteSpace(model.FolderId) ? (Guid?)null : new Guid(model.FolderId), userId); } + [HttpPost("move")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostMoveMany([FromBody] CipherBulkMoveRequestModel model) + { + await MoveMany(model); + } + [HttpPut("share")] - [HttpPost("share")] public async Task> PutShareMany([FromBody] CipherBulkShareRequestModel model) { var organizationId = new Guid(model.Ciphers.First().OrganizationId); @@ -1207,6 +1284,13 @@ public class CiphersController : Controller return new ListResponseModel(response); } + [HttpPost("share")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task> PostShareMany([FromBody] CipherBulkShareRequestModel model) + { + return await PutShareMany(model); + } + [HttpPost("purge")] public async Task PostPurge([FromBody] SecretVerificationRequestModel model, Guid? organizationId = null) { @@ -1325,7 +1409,7 @@ public class CiphersController : Controller [Obsolete("Deprecated Attachments API", false)] [RequestSizeLimit(Constants.FileSize101mb)] [DisableFormValueModelBinding] - public async Task PostAttachment(Guid id) + public async Task PostAttachmentV1(Guid id) { ValidateAttachment(); @@ -1419,7 +1503,6 @@ public class CiphersController : Controller } [HttpDelete("{id}/attachment/{attachmentId}")] - [HttpPost("{id}/attachment/{attachmentId}/delete")] public async Task DeleteAttachment(Guid id, string attachmentId) { var userId = _userService.GetProperUserId(User).Value; @@ -1432,8 +1515,14 @@ public class CiphersController : Controller return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, false); } + [HttpPost("{id}/attachment/{attachmentId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAttachment(Guid id, string attachmentId) + { + return await DeleteAttachment(id, attachmentId); + } + [HttpDelete("{id}/attachment/{attachmentId}/admin")] - [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] public async Task DeleteAttachmentAdmin(Guid id, string attachmentId) { var userId = _userService.GetProperUserId(User).Value; @@ -1447,6 +1536,13 @@ public class CiphersController : Controller return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); } + [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAttachmentAdmin(Guid id, string attachmentId) + { + return await DeleteAttachmentAdmin(id, attachmentId); + } + [AllowAnonymous] [HttpPost("attachment/validate/azure")] public async Task AzureValidateFile() diff --git a/src/Api/Vault/Controllers/FoldersController.cs b/src/Api/Vault/Controllers/FoldersController.cs index 9da9e6a184..195931f60c 100644 --- a/src/Api/Vault/Controllers/FoldersController.cs +++ b/src/Api/Vault/Controllers/FoldersController.cs @@ -45,7 +45,7 @@ public class FoldersController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var folders = await _folderRepository.GetManyByUserIdAsync(userId); @@ -63,7 +63,6 @@ public class FoldersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] FolderRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -77,8 +76,14 @@ public class FoldersController : Controller return new FolderResponseModel(folder); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPut(string id, [FromBody] FolderRequestModel model) + { + return await Put(id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string id) { var userId = _userService.GetProperUserId(User).Value; @@ -91,6 +96,13 @@ public class FoldersController : Controller await _cipherService.DeleteFolderAsync(folder); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDelete(string id) + { + await Delete(id); + } + [HttpDelete("all")] public async Task DeleteAll() { From b9f58946a37904e995f38f8b5649906d97671a00 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:23:29 -0600 Subject: [PATCH 78/85] Fix load test scheduled default path (#6339) --- .github/workflows/load-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index c582e6ba00..9bc6da89e7 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -35,7 +35,7 @@ env: AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH # Specify defaults for scheduled runs TEST_ID: ${{ inputs.test-id || 'server-load-test' }} - K6_TEST_PATH: ${{ inputs.k6-test-path || 'test/load/*.js' }} + K6_TEST_PATH: ${{ inputs.k6-test-path || 'perf/load/*.js' }} API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }} IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }} From 2dd89b488d7bfe567bd57c76f4755426e3bba8c6 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Mon, 15 Sep 2025 14:11:25 -0400 Subject: [PATCH 79/85] Remove archive date from create request (#6341) --- src/Api/Vault/Models/Request/CipherRequestModel.cs | 2 -- src/Core/Vault/Models/Data/CipherDetails.cs | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 467be6e356..7ba72cccb7 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -46,7 +46,6 @@ public class CipherRequestModel public CipherSecureNoteModel SecureNote { get; set; } public CipherSSHKeyModel SSHKey { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; - public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -100,7 +99,6 @@ public class CipherRequestModel existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; - existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index e0ece1efec..e55cfd8cff 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -25,6 +25,7 @@ public class CipherDetails : CipherOrganizationDetails CreationDate = cipher.CreationDate; RevisionDate = cipher.RevisionDate; DeletedDate = cipher.DeletedDate; + ArchivedDate = cipher.ArchivedDate; Reprompt = cipher.Reprompt; Key = cipher.Key; OrganizationUseTotp = cipher.OrganizationUseTotp; From 6c512f1bc24158d4bdf561a74496f5d2d20143ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lison=20Fernandes?= Date: Mon, 15 Sep 2025 20:57:13 +0100 Subject: [PATCH 80/85] Add mobile CXP feature flags (#6343) --- src/Core/Constants.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 17dae1255c..8c7e3ff832 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -206,6 +206,8 @@ public static class FeatureFlagKeys public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; public const string AppIntents = "app-intents"; public const string SendAccess = "pm-19394-send-access-control"; + public const string CxpImportMobile = "cxp-import-mobile"; + public const string CxpExportMobile = "cxp-export-mobile"; /* Platform Team */ public const string IpcChannelFramework = "ipc-channel-framework"; From 4b3ac2ea61869f1c4013f7875bcac711495878fc Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:00:07 -0500 Subject: [PATCH 81/85] chore: resolve merge conflict to delete dc user removal feature flag, refs PM-24596 (#6344) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8c7e3ff832..43bba121df 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,7 +131,6 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; - public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; From da48603c186e7e75ba46f050c8b420586f3c7874 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 16 Sep 2025 11:16:00 -0400 Subject: [PATCH 82/85] Revert "Remove archive date from create request (#6341)" (#6346) This reverts commit 2dd89b488d7bfe567bd57c76f4755426e3bba8c6. --- src/Api/Vault/Models/Request/CipherRequestModel.cs | 2 ++ src/Core/Vault/Models/Data/CipherDetails.cs | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 7ba72cccb7..467be6e356 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -46,6 +46,7 @@ public class CipherRequestModel public CipherSecureNoteModel SecureNote { get; set; } public CipherSSHKeyModel SSHKey { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; + public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -99,6 +100,7 @@ public class CipherRequestModel existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; + existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index e55cfd8cff..e0ece1efec 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -25,7 +25,6 @@ public class CipherDetails : CipherOrganizationDetails CreationDate = cipher.CreationDate; RevisionDate = cipher.RevisionDate; DeletedDate = cipher.DeletedDate; - ArchivedDate = cipher.ArchivedDate; Reprompt = cipher.Reprompt; Key = cipher.Key; OrganizationUseTotp = cipher.OrganizationUseTotp; From 6e309c6e0463cee627e468aaae50489114d6c948 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:00:32 -0700 Subject: [PATCH 83/85] fix cipher org details with collections task (#6342) --- .../Vault/Repositories/CipherRepository.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index c741495f8e..4904574eee 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -902,13 +902,17 @@ public class CipherRepository : Repository, ICipherRepository var dict = new Dictionary(); var tempCollections = new Dictionary>(); - await connection.QueryAsync( + await connection.QueryAsync< + CipherOrganizationDetails, + CollectionCipher, + CipherOrganizationDetailsWithCollections + >( $"[{Schema}].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections]", (cipher, cc) => { if (!dict.TryGetValue(cipher.Id, out var details)) { - details = new CipherOrganizationDetailsWithCollections(cipher, /*dummy*/null); + details = new CipherOrganizationDetailsWithCollections(cipher, new Dictionary>()); dict.Add(cipher.Id, details); tempCollections[cipher.Id] = new List(); } @@ -925,7 +929,6 @@ public class CipherRepository : Repository, ICipherRepository commandType: CommandType.StoredProcedure ); - // now assign each List back to the array property in one shot foreach (var kv in dict) { kv.Value.CollectionIds = tempCollections[kv.Key].ToArray(); From 57f891f391c730aff2ff479766b1e5687a753cbc Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:01:23 -0400 Subject: [PATCH 84/85] feat(sso): [auth/pm-17719] Make SSO identifier errors consistent (#6345) * feat(sso-account-controller): Make SSO identifiers consistent - align all return messages from prevalidate. * feat(shared-resources): Make SSO identifiers consistent - remove unused string resources, add new consistent error message. * feat(sso-account-controller): Make SSO identifiers consistent - Add logging. --- .../src/Sso/Controllers/AccountController.cs | 42 ++++++++----------- src/Core/Resources/SharedResources.en.resx | 18 ++------ 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 30b0d168d0..98a581e8ca 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -108,36 +108,32 @@ public class AccountController : Controller // Validate domain_hint provided if (string.IsNullOrWhiteSpace(domainHint)) { - return InvalidJson("NoOrganizationIdentifierProvidedError"); + _logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate organization exists from domain_hint var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); - if (organization == null) + if (organization is not { UseSso: true }) { - return InvalidJson("OrganizationNotFoundByIdentifierError"); - } - if (!organization.UseSso) - { - return InvalidJson("SsoNotAllowedForOrganizationError"); + _logger.LogError("Organization not configured to use SSO."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate SsoConfig exists and is Enabled var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); - if (ssoConfig == null) + if (ssoConfig is not { Enabled: true }) { - return InvalidJson("SsoConfigurationNotFoundForOrganizationError"); - } - if (!ssoConfig.Enabled) - { - return InvalidJson("SsoNotEnabledForOrganizationError"); + _logger.LogError("SsoConfig not enabled."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate Authentication Scheme exists and is loaded (cache) var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); - if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme)) + if (scheme is not IDynamicAuthenticationScheme dynamicScheme) { - return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); + _logger.LogError("Invalid authentication scheme for organization."); + return InvalidJson("SsoInvalidIdentifierError"); } // Run scheme validation @@ -147,13 +143,8 @@ public class AccountController : Controller } catch (Exception ex) { - var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message); - var errorKey = "InvalidSchemeConfigurationError"; - if (!translatedException.ResourceNotFound) - { - errorKey = ex.Message; - } - return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null); + _logger.LogError(ex, "An error occurred while validating SSO dynamic scheme."); + return InvalidJson("SsoInvalidIdentifierError"); } var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds); @@ -163,7 +154,8 @@ public class AccountController : Controller } catch (Exception ex) { - return InvalidJson("PreValidationError", ex); + _logger.LogError(ex, "An error occurred during SSO prevalidation."); + return InvalidJson("SsoInvalidIdentifierError"); } } @@ -352,7 +344,7 @@ public class AccountController : Controller } /// - /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. + /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. /// private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)> @@ -485,7 +477,7 @@ public class AccountController : Controller allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]); - // Since we're in the auto-provisioning logic, this means that the user exists, but they have not + // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed // with authentication. diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 97cac5a610..17b4489454 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -394,24 +394,9 @@ The configured authentication scheme is not valid: "{0}" - - No scheme or handler for this SSO configuration found. - - - SSO is not yet enabled for this organization. - - - No SSO configuration exists for this organization. - - - SSO is not allowed for this organization. - Organization not found from identifier. - - No organization identifier provided. - Invalid authentication options provided to SAML2 scheme. @@ -691,4 +676,7 @@ Single sign on redirect token is invalid or expired. + + Invalid SSO identifier + From d83395aeb07226d5fb3df9ef14602eeab874392e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:43:27 +0100 Subject: [PATCH 85/85] [PM-25372] Filter out DefaultUserCollections from CiphersController.GetAssignedOrganizationCiphers (#6274) Co-authored-by: Jimmy Vo --- .../ICollectionCipherRepository.cs | 1 + .../Vault/Queries/OrganizationCiphersQuery.cs | 2 +- .../CollectionCipherRepository.cs | 13 +++ .../CollectionCipherRepository.cs | 15 ++++ ...ctionCipher_ReadSharedByOrganizationId.sql | 17 ++++ src/Sql/dbo/Tables/Collection.sql | 2 +- .../CollectionCipherRepositoryTests.cs | 84 +++++++++++++++++++ ...llectionCipherManySharedByOrganization.sql | 30 +++++++ 8 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql create mode 100644 test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 9494fec0ec..f7a4081b73 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -8,6 +8,7 @@ public interface ICollectionCipherRepository { Task> GetManyByUserIdAsync(Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task> GetManySharedByOrganizationIdAsync(Guid organizationId); Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId); Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable collectionIds); Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index 945fdb7e3c..62b055b417 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -24,7 +24,7 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var orgCiphers = ciphers.Where(c => c.OrganizationId == organizationId).ToList(); var orgCipherIds = orgCiphers.Select(c => c.Id); - var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(organizationId); + var collectionCiphers = await _collectionCipherRepository.GetManySharedByOrganizationIdAsync(organizationId); var collectionCiphersGroupDict = collectionCiphers .Where(c => orgCipherIds.Contains(c.CipherId)) .GroupBy(c => c.CipherId).ToDictionary(s => s.Key); diff --git a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs index 5ed82a9a2c..64b1a74072 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs @@ -45,6 +45,19 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[CollectionCipher_ReadSharedByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index d0787f7303..6e2805f987 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -47,6 +47,21 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var data = await (from cc in dbContext.CollectionCiphers + join c in dbContext.Collections + on cc.CollectionId equals c.Id + where c.OrganizationId == organizationId + && c.Type == Core.Enums.CollectionType.SharedCollection + select cc).ToArrayAsync(); + return data; + } + } + public async Task> GetManyByUserIdAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql new file mode 100644 index 0000000000..d35dabb0e4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 03064fd978..2f0d3b943b 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -14,6 +14,6 @@ GO CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection]([OrganizationId] ASC) - INCLUDE([CreationDate], [Name], [RevisionDate]); + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs new file mode 100644 index 0000000000..1579e5c329 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs @@ -0,0 +1,84 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Vault.Repositories; + +public class CollectionCipherRepositoryTests +{ + [Theory, DatabaseData] + public async Task GetManySharedByOrganizationIdAsync_OnlyReturnsSharedCollections( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ICipherRepository cipherRepository, + ICollectionCipherRepository collectionCipherRepository) + { + // Arrange + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Enterprise", + BillingEmail = "billing@example.com" + }); + + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + var defaultUserCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Default User Collection", + OrganizationId = organization.Id, + Type = CollectionType.DefaultUserCollection + }); + + var sharedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var defaultCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { sharedCipher.Id }, + new[] { sharedCollection.Id }); + + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { defaultCipher.Id }, + new[] { defaultUserCollection.Id }); + + // Act + var result = await collectionCipherRepository.GetManySharedByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Single(result); + Assert.Equal(sharedCollection.Id, result.First().CollectionId); + Assert.DoesNotContain(result, cc => cc.CollectionId == defaultUserCollection.Id); + + // Cleanup + await cipherRepository.DeleteAsync(sharedCipher); + await cipherRepository.DeleteAsync(defaultCipher); + await collectionRepository.DeleteAsync(sharedCollection); + await collectionRepository.DeleteAsync(defaultUserCollection); + await organizationRepository.DeleteAsync(organization); + } +} diff --git a/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql b/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql new file mode 100644 index 0000000000..d29856ca00 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql @@ -0,0 +1,30 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END +GO + +-- Update [IX_Collection_OrganizationId_IncludeAll] index to include [Type] column +IF EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_Collection_OrganizationId_IncludeAll' AND object_id = OBJECT_ID('[dbo].[Collection]')) +BEGIN + DROP INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection] +END +GO + +CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] + ON [dbo].[Collection]([OrganizationId] ASC) + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]) +GO