From 5a712ebb6b18d097affe6054b60b0c6e9ef815a6 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 25 Aug 2025 02:43:24 -0400 Subject: [PATCH 001/163] Xunit v3 (#6241) * Initial v3 Migration * Migrate tests and debug duplicate ids * Debug duplicate ids * Support seeding * remove seeder * Upgrade to latest XUnit.v3 version * Remove Theory changes for now * Remove Theory change from DeviceRepositoryTests * Remove cancellation token additions --- .../DatabaseDataAttribute.cs | 293 +++++++++++------- .../DatabaseTheoryAttribute.cs | 23 +- .../DistributedCacheTests.cs | 4 +- .../Infrastructure.IntegrationTest.csproj | 4 +- .../XUnitLoggerProvider.cs | 47 +++ 5 files changed, 235 insertions(+), 136 deletions(-) create mode 100644 test/Infrastructure.IntegrationTest/XUnitLoggerProvider.cs diff --git a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs index 498cc668c0..c458969748 100644 --- a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs +++ b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs @@ -10,129 +10,29 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; +using Xunit; using Xunit.Sdk; +using Xunit.v3; namespace Bit.Infrastructure.IntegrationTest; public class DatabaseDataAttribute : DataAttribute { + private static IConfiguration? _cachedConfiguration; + private static IConfiguration GetConfiguration() + { + return _cachedConfiguration ??= new ConfigurationBuilder() + .AddUserSecrets(optional: true, reloadOnChange: false) + .AddEnvironmentVariables("BW_TEST_") + .AddCommandLine(Environment.GetCommandLineArgs()) + .Build(); + } + + public bool SelfHosted { get; set; } public bool UseFakeTimeProvider { get; set; } public string? MigrationName { get; set; } - public override IEnumerable GetData(MethodInfo testMethod) - { - var parameters = testMethod.GetParameters(); - - var config = DatabaseTheoryAttribute.GetConfiguration(); - - var serviceProviders = GetDatabaseProviders(config); - - foreach (var provider in serviceProviders) - { - var objects = new object[parameters.Length]; - for (var i = 0; i < parameters.Length; i++) - { - objects[i] = provider.GetRequiredService(parameters[i].ParameterType); - } - yield return objects; - } - } - - protected virtual IEnumerable GetDatabaseProviders(IConfiguration config) - { - // This is for the device repository integration testing. - var userRequestExpiration = 15; - - var configureLogging = (ILoggingBuilder builder) => - { - if (!config.GetValue("Quiet")) - { - builder.AddConfiguration(config); - builder.AddConsole(); - builder.AddDebug(); - } - }; - - var databases = config.GetDatabases(); - - foreach (var database in databases) - { - if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) - { - var dapperSqlServerCollection = new ServiceCollection(); - AddCommonServices(dapperSqlServerCollection, configureLogging); - dapperSqlServerCollection.AddDapperRepositories(SelfHosted); - var globalSettings = new GlobalSettings - { - DatabaseProvider = "sqlServer", - SqlServer = new GlobalSettings.SqlSettings - { - ConnectionString = database.ConnectionString, - }, - PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings - { - UserRequestExpiration = TimeSpan.FromMinutes(userRequestExpiration), - } - }; - dapperSqlServerCollection.AddSingleton(globalSettings); - dapperSqlServerCollection.AddSingleton(globalSettings); - dapperSqlServerCollection.AddSingleton(database); - dapperSqlServerCollection.AddDistributedSqlServerCache(o => - { - o.ConnectionString = database.ConnectionString; - o.SchemaName = "dbo"; - o.TableName = "Cache"; - }); - - if (!string.IsNullOrEmpty(MigrationName)) - { - AddSqlMigrationTester(dapperSqlServerCollection, database.ConnectionString, MigrationName); - } - - yield return dapperSqlServerCollection.BuildServiceProvider(); - } - else - { - var efCollection = new ServiceCollection(); - AddCommonServices(efCollection, configureLogging); - efCollection.SetupEntityFramework(database.ConnectionString, database.Type); - efCollection.AddPasswordManagerEFRepositories(SelfHosted); - - var globalSettings = new GlobalSettings - { - PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings - { - UserRequestExpiration = TimeSpan.FromMinutes(userRequestExpiration), - } - }; - efCollection.AddSingleton(globalSettings); - efCollection.AddSingleton(globalSettings); - - efCollection.AddSingleton(database); - efCollection.AddSingleton(); - - if (!string.IsNullOrEmpty(MigrationName)) - { - AddEfMigrationTester(efCollection, database.Type, MigrationName); - } - - yield return efCollection.BuildServiceProvider(); - } - } - } - - private void AddCommonServices(IServiceCollection services, Action configureLogging) - { - services.AddLogging(configureLogging); - services.AddDataProtection(); - - if (UseFakeTimeProvider) - { - services.AddSingleton(); - } - } - private void AddSqlMigrationTester(IServiceCollection services, string connectionString, string migrationName) { services.AddSingleton(_ => new SqlMigrationTesterService(connectionString, migrationName)); @@ -146,4 +46,171 @@ public class DatabaseDataAttribute : DataAttribute return new EfMigrationTesterService(dbContext, databaseType, migrationName); }); } + + public override ValueTask> GetData(MethodInfo testMethod, DisposalTracker disposalTracker) + { + var config = GetConfiguration(); + + HashSet unconfiguredDatabases = + [ + SupportedDatabaseProviders.MySql, + SupportedDatabaseProviders.Postgres, + SupportedDatabaseProviders.Sqlite, + SupportedDatabaseProviders.SqlServer + ]; + + var theories = new List(); + + foreach (var database in config.GetDatabases()) + { + unconfiguredDatabases.Remove(database.Type); + + if (!database.Enabled) + { + var theory = new TheoryDataRow() + .WithSkip("Not-Enabled") + .WithTrait("Database", database.Type.ToString()); + theory.Label = database.Type.ToString(); + theories.Add(theory); + continue; + } + + var services = new ServiceCollection(); + AddCommonServices(services); + + if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) + { + // Dapper services + AddDapperServices(services, database); + } + else + { + // Ef services + AddEfServices(services, database); + } + + var serviceProvider = services.BuildServiceProvider(); + disposalTracker.Add(serviceProvider); + + var serviceTheory = new ServiceBasedTheoryDataRow(serviceProvider, testMethod) + .WithTrait("Database", database.Type.ToString()) + .WithTrait("ConnectionString", database.ConnectionString); + + serviceTheory.Label = database.Type.ToString(); + theories.Add(serviceTheory); + } + + foreach (var unconfiguredDatabase in unconfiguredDatabases) + { + var theory = new TheoryDataRow() + .WithSkip("Unconfigured") + .WithTrait("Database", unconfiguredDatabase.ToString()); + theory.Label = unconfiguredDatabase.ToString(); + theories.Add(theory); + } + + return new(theories); + } + + private void AddCommonServices(IServiceCollection services) + { + // Common services + services.AddDataProtection(); + services.AddLogging(logging => + { + logging.AddProvider(new XUnitLoggerProvider()); + }); + if (UseFakeTimeProvider) + { + services.AddSingleton(); + } + } + + private void AddDapperServices(IServiceCollection services, Database database) + { + services.AddDapperRepositories(SelfHosted); + var globalSettings = new GlobalSettings + { + DatabaseProvider = "sqlServer", + SqlServer = new GlobalSettings.SqlSettings + { + ConnectionString = database.ConnectionString, + }, + PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings + { + UserRequestExpiration = TimeSpan.FromMinutes(15), + } + }; + services.AddSingleton(globalSettings); + services.AddSingleton(globalSettings); + services.AddSingleton(database); + services.AddDistributedSqlServerCache(o => + { + o.ConnectionString = database.ConnectionString; + o.SchemaName = "dbo"; + o.TableName = "Cache"; + }); + + if (!string.IsNullOrEmpty(MigrationName)) + { + AddSqlMigrationTester(services, database.ConnectionString, MigrationName); + } + } + + private void AddEfServices(IServiceCollection services, Database database) + { + services.SetupEntityFramework(database.ConnectionString, database.Type); + services.AddPasswordManagerEFRepositories(SelfHosted); + + var globalSettings = new GlobalSettings + { + PasswordlessAuth = new GlobalSettings.PasswordlessAuthSettings + { + UserRequestExpiration = TimeSpan.FromMinutes(15), + }, + }; + services.AddSingleton(globalSettings); + services.AddSingleton(globalSettings); + + services.AddSingleton(database); + services.AddSingleton(); + + if (!string.IsNullOrEmpty(MigrationName)) + { + AddEfMigrationTester(services, database.Type, MigrationName); + } + } + + public override bool SupportsDiscoveryEnumeration() + { + return true; + } + + private class ServiceBasedTheoryDataRow : TheoryDataRowBase + { + private readonly IServiceProvider _serviceProvider; + private readonly MethodInfo _testMethod; + + public ServiceBasedTheoryDataRow(IServiceProvider serviceProvider, MethodInfo testMethod) + { + _serviceProvider = serviceProvider; + _testMethod = testMethod; + } + + protected override object?[] GetData() + { + var parameters = _testMethod.GetParameters(); + + var services = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + // TODO: Could support keyed services/optional/nullable + services[i] = _serviceProvider.GetRequiredService(parameter.ParameterType); + } + + return services; + } + } } diff --git a/test/Infrastructure.IntegrationTest/DatabaseTheoryAttribute.cs b/test/Infrastructure.IntegrationTest/DatabaseTheoryAttribute.cs index 1dc6dc76ed..f897220652 100644 --- a/test/Infrastructure.IntegrationTest/DatabaseTheoryAttribute.cs +++ b/test/Infrastructure.IntegrationTest/DatabaseTheoryAttribute.cs @@ -1,32 +1,17 @@ -using Microsoft.Extensions.Configuration; +using System.Runtime.CompilerServices; using Xunit; namespace Bit.Infrastructure.IntegrationTest; +[Obsolete("This attribute is no longer needed and can be replaced with a [Theory]")] public class DatabaseTheoryAttribute : TheoryAttribute { - private static IConfiguration? _cachedConfiguration; - public DatabaseTheoryAttribute() { - if (!HasAnyDatabaseSetup()) - { - Skip = "No databases setup."; - } + } - private static bool HasAnyDatabaseSetup() + public DatabaseTheoryAttribute([CallerFilePath] string? sourceFilePath = null, [CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber) { - var config = GetConfiguration(); - return config.GetDatabases().Length > 0; - } - - public static IConfiguration GetConfiguration() - { - return _cachedConfiguration ??= new ConfigurationBuilder() - .AddUserSecrets(optional: true, reloadOnChange: false) - .AddEnvironmentVariables("BW_TEST_") - .AddCommandLine(Environment.GetCommandLineArgs()) - .Build(); } } diff --git a/test/Infrastructure.IntegrationTest/DistributedCacheTests.cs b/test/Infrastructure.IntegrationTest/DistributedCacheTests.cs index 875f9d16c6..974b8e0c18 100644 --- a/test/Infrastructure.IntegrationTest/DistributedCacheTests.cs +++ b/test/Infrastructure.IntegrationTest/DistributedCacheTests.cs @@ -65,7 +65,7 @@ public class DistributedCacheTests [DatabaseTheory, DatabaseData] public async Task MultipleWritesOnSameKey_ShouldNotThrow(IDistributedCache cache) { - await cache.SetAsync("test-duplicate", "some-value"u8.ToArray()); - await cache.SetAsync("test-duplicate", "some-value"u8.ToArray()); + await cache.SetAsync("test-duplicate", "some-value"u8.ToArray(), TestContext.Current.CancellationToken); + await cache.SetAsync("test-duplicate", "some-value"u8.ToArray(), TestContext.Current.CancellationToken); } } diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index 6d9e0d6667..a2215e3453 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -12,8 +12,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/Infrastructure.IntegrationTest/XUnitLoggerProvider.cs b/test/Infrastructure.IntegrationTest/XUnitLoggerProvider.cs new file mode 100644 index 0000000000..43310496f5 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/XUnitLoggerProvider.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest; + +public sealed class XUnitLoggerProvider : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) + { + return new XUnitLogger(categoryName); + } + + public void Dispose() + { + + } + + private class XUnitLogger : ILogger + { + private readonly string _categoryName; + + public XUnitLogger(string categoryName) + { + _categoryName = categoryName; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (TestContext.Current?.TestOutputHelper is not ITestOutputHelper testOutputHelper) + { + return; + } + + testOutputHelper.WriteLine($"[{_categoryName}] {formatter(state, exception)}"); + } + } +} From 236027fc22cf490ad8317841e44cf3dcf54f0c36 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 25 Aug 2025 11:01:27 +0000 Subject: [PATCH 002/163] Bumped version to 2025.8.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index d8af8dc990..3af05be0f1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.8.0 + 2025.8.1 Bit.$(MSBuildProjectName) enable From a7fc89a5bb5a85855251fdd34ca2886621099c4f Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 25 Aug 2025 14:34:06 -0500 Subject: [PATCH 003/163] 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 004/163] 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 005/163] 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 006/163] [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 007/163] =?UTF-8?q?[PM-22838]=C2=A0Add=20hyperlink=20to=20?= =?UTF-8?q?provider=20name=20in=20Admin=20Panel=20organization=20details?= =?UTF-8?q?=20(#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 008/163] [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 009/163] 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 010/163] [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 011/163] [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 012/163] 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 013/163] 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 014/163] 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 015/163] 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 016/163] 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 017/163] [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 018/163] 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 019/163] [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 020/163] 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 021/163] [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 022/163] [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 023/163] 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 024/163] [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 025/163] 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 026/163] [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 027/163] [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 028/163] [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 029/163] 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 030/163] [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 031/163] 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 032/163] [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 033/163] 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 034/163] [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 035/163] 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 036/163] 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 037/163] [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 038/163] [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 039/163] [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 040/163] [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 041/163] [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 042/163] [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 043/163] 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 044/163] 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 045/163] 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 046/163] [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 047/163] 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 048/163] 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 049/163] 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 050/163] 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 051/163] 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 052/163] [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 053/163] [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 054/163] [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 055/163] [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 056/163] [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 057/163] [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 058/163] 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 059/163] [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 060/163] [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 061/163] [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 062/163] 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 063/163] [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 064/163] =?UTF-8?q?[PM-24192]=20Add=20OrganizationContext?= =?UTF-8?q?=20in=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 065/163] [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 066/163] 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 067/163] [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 068/163] [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 069/163] 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 070/163] [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 071/163] 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 072/163] [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 073/163] [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 074/163] [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 075/163] 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 076/163] [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 077/163] 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 078/163] [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 079/163] [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 080/163] 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 081/163] 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 082/163] 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 083/163] 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 084/163] 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 085/163] 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 086/163] 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 087/163] [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 From 26e574e8d75f0dda47bfcb9ae12b11783096ade1 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 17 Sep 2025 17:14:00 -0400 Subject: [PATCH 088/163] Auth/pm 25453/support n users auth request (#6347) * fix(pending-auth-request-view): [PM-25453] Bugfix Auth Requests Multiple Users Same Device - fixed view to allow for multiple users for each device when partitioning for the auth request view. --- .../Views/AuthRequestPendingDetailsView.sql | 2 +- ...00_UpdateAuthRequestPendingDetailsView.sql | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql diff --git a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql index d0433bca09..16f8a51195 100644 --- a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql +++ b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql @@ -7,7 +7,7 @@ AS SELECT [AR].*, [D].[Id] AS [DeviceId], - ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn] + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier], [AR].[UserId] ORDER BY [AR].[CreationDate] DESC) AS [rn] FROM [dbo].[AuthRequest] [AR] LEFT JOIN [dbo].[Device] [D] ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] diff --git a/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql b/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql new file mode 100644 index 0000000000..21b51387af --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql @@ -0,0 +1,42 @@ +CREATE OR ALTER VIEW [dbo].[AuthRequestPendingDetailsView] +AS + WITH + PendingRequests + AS + ( + SELECT + [AR].*, + [D].[Id] AS [DeviceId], + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier], [AR].[UserId] ORDER BY [AR].[CreationDate] DESC) AS [rn] + FROM [dbo].[AuthRequest] [AR] + LEFT JOIN [dbo].[Device] [D] + ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] + AND [D].[UserId] = [AR].[UserId] + WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock + ) +SELECT + [PR].[Id], + [PR].[UserId], + [PR].[OrganizationId], + [PR].[Type], + [PR].[RequestDeviceIdentifier], + [PR].[RequestDeviceType], + [PR].[RequestIpAddress], + [PR].[RequestCountryName], + [PR].[ResponseDeviceId], + [PR].[AccessCode], + [PR].[PublicKey], + [PR].[Key], + [PR].[MasterPasswordHash], + [PR].[Approved], + [PR].[CreationDate], + [PR].[ResponseDate], + [PR].[AuthenticationDate], + [PR].[DeviceId] +FROM [PendingRequests] [PR] +WHERE [PR].[rn] = 1 + AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null +GO + +EXECUTE sp_refreshsqlmodule N'[dbo].[AuthRequestPendingDetailsView]' +GO From e46365ac206be26c393c8d0198bbd8aa8c5e24c7 Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:36:00 -0700 Subject: [PATCH 089/163] feat(policies): add URI Match Defaults organizational policy (#6294) * feat(policies): add URI Match Defaults organizational policy Signed-off-by: Ben Brooks * feat(policies): remove unecessary model and org feature Signed-off-by: Ben Brooks --------- Signed-off-by: Ben Brooks --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index ab39e543f8..f038faaf0b 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -18,6 +18,7 @@ public enum PolicyType : byte FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, + UriMatchDefaults = 16, } public static class PolicyTypeExtensions @@ -46,6 +47,7 @@ public static class PolicyTypeExtensions PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", + PolicyType.UriMatchDefaults => "URI match defaults", }; } } From 780400fcf9ed8a7f4650e01ac825a9a480e49121 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:50:36 +1000 Subject: [PATCH 090/163] [PM-25138] Reduce db locking when creating default collections (#6308) * Use single method for default collection creation * Use GenerateComb to create sequential guids * Pre-sort data for SqlBulkCopy * Add SqlBulkCopy options per dbops recommendations --- .../ConfirmOrganizationUserCommand.cs | 62 ++++++++++++++++--- ...anizationDataOwnershipPolicyRequirement.cs | 5 ++ .../Repositories/ICollectionRepository.cs | 9 ++- src/Core/Utilities/CoreHelpers.cs | 7 ++- .../Helpers/BulkResourceCreationService.cs | 33 ++++++++-- .../Repositories/CollectionRepository.cs | 10 +-- .../Utilities/SqlGuidHelpers.cs | 26 ++++++++ .../Repositories/CollectionRepository.cs | 11 ++-- .../ConfirmOrganizationUserCommandTests.cs | 42 ++++++++++--- 9 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 83ec244c47..2fbe6be5c6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -82,7 +83,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand throw new BadRequestException(error); } - await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers: [orgUser], defaultUserCollectionName); + await CreateDefaultCollectionAsync(orgUser, defaultUserCollectionName); return orgUser; } @@ -97,9 +98,13 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand .Select(r => r.Item1) .ToList(); - if (confirmedOrganizationUsers.Count > 0) + if (confirmedOrganizationUsers.Count == 1) { - await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName); + await CreateDefaultCollectionAsync(confirmedOrganizationUsers.Single(), defaultUserCollectionName); + } + else if (confirmedOrganizationUsers.Count > 1) + { + await CreateManyDefaultCollectionsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName); } return result; @@ -245,14 +250,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } /// - /// Handles the side effects of confirming an organization user. - /// Creates a default collection for the user if the organization - /// has the OrganizationDataOwnership policy enabled. + /// Creates a default collection for a single user if required by the Organization Data Ownership policy. + /// + /// The organization user who has just been confirmed. + /// The encrypted default user collection name. + private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + // Skip if no collection name provided (backwards compatibility) + if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) + { + return; + } + + var organizationDataOwnershipPolicy = + await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); + if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId)) + { + return; + } + + var defaultCollection = new Collection + { + OrganizationId = organizationUser.OrganizationId, + Name = defaultUserCollectionName, + Type = CollectionType.DefaultUserCollection + }; + var collectionUser = new CollectionAccessSelection + { + Id = organizationUser.Id, + ReadOnly = false, + HidePasswords = false, + Manage = true + }; + + await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]); + } + + /// + /// Creates default collections for multiple users if required by the Organization Data Ownership policy. /// /// The organization ID. /// The confirmed organization users. /// The encrypted default user collection name. - private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, + private async Task CreateManyDefaultCollectionsAsync(Guid organizationId, IEnumerable confirmedOrganizationUsers, string defaultUserCollectionName) { if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) @@ -266,7 +311,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var policyEligibleOrganizationUserIds = await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + var policyEligibleOrganizationUserIds = + await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); var eligibleOrganizationUserIds = confirmedOrganizationUsers .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index cb72a51850..28d6614dcb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -67,6 +67,11 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false); return noCollectionNeeded; } + + public bool RequiresDefaultCollectionOnConfirm(Guid organizationId) + { + return _policyDetails.Any(p => p.OrganizationId == organizationId); + } } public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index ca3e52751c..f86147ca7d 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -63,5 +63,12 @@ public interface ICollectionRepository : IRepository Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); - Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); + /// + /// Creates default user collections for the specified organization users if they do not already have one. + /// + /// The Organization ID. + /// The Organization User IDs to create default collections for. + /// The encrypted string to use as the default collection name. + /// + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 813eb6d1aa..5acdc63489 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -41,9 +41,12 @@ public static class CoreHelpers }; /// - /// Generate sequential Guid for Sql Server. - /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// Generate a sequential Guid for Sql Server. This prevents SQL Server index fragmentation by incorporating timestamp + /// information for sequential ordering. This should be preferred to for any database IDs. /// + /// + /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// /// A comb Guid. public static Guid GenerateComb() => GenerateComb(Guid.NewGuid(), DateTime.UtcNow); diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 3610c1c484..5a743ba028 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -1,6 +1,7 @@ using System.Data; using Bit.Core.Entities; using Bit.Core.Vault.Entities; +using Bit.Infrastructure.Dapper.Utilities; using Microsoft.Data.SqlClient; namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; @@ -8,11 +9,25 @@ namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; public static class BulkResourceCreationService { private const string _defaultErrorMessage = "Must have at least one record for bulk creation."; - public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collectionUsers, string errorMessage = _defaultErrorMessage) + public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable collectionUsers, string errorMessage = _defaultErrorMessage) { + // Offload some work from SQL Server by pre-sorting before insert. + // This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks. + var sortedCollectionUsers = collectionUsers + .OrderBySqlGuid(cu => cu.CollectionId) + .ThenBySqlGuid(cu => cu.OrganizationUserId) + .ToList(); + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); bulkCopy.DestinationTableName = "[dbo].[CollectionUser]"; - var dataTable = BuildCollectionsUsersTable(bulkCopy, collectionUsers, errorMessage); + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + bulkCopy.ColumnOrderHints.Add("CollectionId", SortOrder.Ascending); + bulkCopy.ColumnOrderHints.Add("OrganizationUserId", SortOrder.Ascending); + + var dataTable = BuildCollectionsUsersTable(bulkCopy, sortedCollectionUsers, errorMessage); await bulkCopy.WriteToServerAsync(dataTable); } @@ -96,11 +111,21 @@ public static class BulkResourceCreationService return table; } - public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collections, string errorMessage = _defaultErrorMessage) + public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable collections, string errorMessage = _defaultErrorMessage) { + // Offload some work from SQL Server by pre-sorting before insert. + // This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks. + var sortedCollections = collections.OrderBySqlGuid(c => c.Id).ToList(); + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); bulkCopy.DestinationTableName = "[dbo].[Collection]"; - var dataTable = BuildCollectionsTable(bulkCopy, collections, errorMessage); + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + bulkCopy.ColumnOrderHints.Add("Id", SortOrder.Ascending); + + var dataTable = BuildCollectionsTable(bulkCopy, sortedCollections, errorMessage); await bulkCopy.WriteToServerAsync(dataTable); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index ad00ac7086..c2a59f75aa 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -6,6 +6,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Dapper; using Microsoft.Data.SqlClient; @@ -326,9 +327,10 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { - if (!affectedOrgUserIds.Any()) + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) { return; } @@ -340,7 +342,7 @@ public class CollectionRepository : Repository, ICollectionRep { var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(connection, transaction, organizationId); - var missingDefaultCollectionUserIds = affectedOrgUserIds.Except(orgUserIdWithDefaultCollection); + var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); @@ -393,7 +395,7 @@ public class CollectionRepository : Repository, ICollectionRep foreach (var orgUserId in missingDefaultCollectionUserIds) { - var collectionId = Guid.NewGuid(); + var collectionId = CoreHelpers.GenerateComb(); collections.Add(new Collection { diff --git a/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs new file mode 100644 index 0000000000..fc548e2ff0 --- /dev/null +++ b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs @@ -0,0 +1,26 @@ +using System.Data.SqlTypes; + +namespace Bit.Infrastructure.Dapper.Utilities; + +public static class SqlGuidHelpers +{ + /// + /// Sorts the source IEnumerable by the specified Guid property using the comparison logic. + /// This is required because MSSQL server compares (and therefore sorts) Guids differently to C#. + /// Ref: https://learn.microsoft.com/en-us/sql/connect/ado-net/sql/compare-guid-uniqueidentifier-values + /// + public static IOrderedEnumerable OrderBySqlGuid( + this IEnumerable source, + Func keySelector) + { + return source.OrderBy(x => new SqlGuid(keySelector(x))); + } + + /// + public static IOrderedEnumerable ThenBySqlGuid( + this IOrderedEnumerable source, + Func keySelector) + { + return source.ThenBy(x => new SqlGuid(keySelector(x))); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 021b5bcf16..5aa156d1f8 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using LinqToDB.EntityFrameworkCore; @@ -793,9 +794,10 @@ public class CollectionRepository : Repository affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { - if (!affectedOrgUserIds.Any()) + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) { return; } @@ -804,8 +806,7 @@ public class CollectionRepository : Repository().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); + var policyDetails = new PolicyDetails + { + OrganizationId = organization.Id, + OrganizationUserId = orgUser.Id, + IsProvider = false, + OrganizationUserStatus = orgUser.Status, + OrganizationUserType = orgUser.Type, + PolicyType = PolicyType.OrganizationDataOwnership + }; sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(organization.Id) - .Returns(new List { orgUser.Id }); + .GetAsync(orgUser.UserId!.Value) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() .Received(1) - .UpsertDefaultCollectionsAsync( - organization.Id, - Arg.Is>(ids => ids.Contains(orgUser.Id)), - collectionName); + .CreateAsync( + Arg.Is(c => + c.Name == collectionName && + c.OrganizationId == organization.Id && + c.Type == CollectionType.DefaultUserCollection), + Arg.Any>(), + Arg.Is>(cu => + cu.Single().Id == orgUser.Id && + cu.Single().Manage)); } [Theory, BitAutoData] @@ -511,7 +526,7 @@ public class ConfirmOrganizationUserCommandTests [Theory, BitAutoData] public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection( Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + [OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Owner)] OrganizationUser orgUser, User user, string key, string collectionName, SutProvider sutProvider) { org.PlanType = PlanType.EnterpriseAnnually; @@ -523,9 +538,18 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); + var policyDetails = new PolicyDetails + { + OrganizationId = org.Id, + OrganizationUserId = orgUser.Id, + IsProvider = false, + OrganizationUserStatus = orgUser.Status, + OrganizationUserType = orgUser.Type, + PolicyType = PolicyType.OrganizationDataOwnership + }; sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(org.Id) - .Returns(new List { orgUser.UserId!.Value }); + .GetAsync(orgUser.UserId!.Value) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); From 866a572d26c842cbddeffab9cc0f3378db90ee80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 18 Sep 2025 13:41:19 +0200 Subject: [PATCH 091/163] Enable custom IDs for bindings (#6340) * Enable custom IDs for bindings * Remove description --- src/Api/Controllers/CollectionsController.cs | 2 +- src/Api/Controllers/DevicesController.cs | 2 +- src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs | 4 ---- src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs | 7 ++++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index f037ab7034..b3542cfde2 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -199,7 +199,7 @@ public class CollectionsController : Controller [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] - public async Task Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) + public async Task PostPut(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { return await Put(orgId, id, model); } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 1f2cda9cc4..d54b3c7b8c 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -115,7 +115,7 @@ public class DevicesController : Controller [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] - public async Task Post(string id, [FromBody] DeviceRequestModel model) + public async Task PostPut(string id, [FromBody] DeviceRequestModel model) { return await Put(id, model); } diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs index fba8b17078..68c0b5145a 100644 --- a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -23,10 +23,6 @@ public class SourceFileLineOperationFilter : IOperationFilter var (fileName, lineNumber) = GetSourceFileLine(context.MethodInfo); if (fileName != null && lineNumber > 0) { - // 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/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)); operation.Extensions.Add("x-source-line", new OpenApiInteger(lineNumber)); diff --git a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs index 60803705d6..d57ee72d48 100644 --- a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs +++ b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs @@ -19,9 +19,10 @@ public static class SwaggerGenOptionsExt // 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(); + config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + // Because we're setting custom operation IDs, we need to ensure that we don't accidentally + // introduce duplicate IDs, which is against the OpenAPI specification and could lead to issues. + config.DocumentFilter(); // These two filters require debug symbols/git, so only add them in development mode if (environment.IsDevelopment()) From c93c3464732c93c9be593a3a55b032c029c4bd6f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:50:24 +0200 Subject: [PATCH 092/163] [deps] Platform: Update LaunchDarkly.ServerSdk to 8.10.1 (#6210) 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 a7db90e892..e76af0f8ef 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -59,7 +59,7 @@ - + From 9d3d35e0bf3f6f15d7705ffa12069bc427a600e0 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 18 Sep 2025 11:22:22 -0500 Subject: [PATCH 093/163] removing status from org name. (#6350) --- src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index e43d5a72bd..6e5504c73a 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -31,7 +31,7 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel ? 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) + orgUser.Status, + OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), Email = WebUtility.UrlEncode(orgUser.Email), OrganizationId = orgUser.OrganizationId.ToString(), OrganizationUserId = orgUser.Id.ToString(), From 7e4dac98378e80e7fd2f60a510c877092889c30f Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:08:47 -0500 Subject: [PATCH 094/163] chore: remove FF, references, and restructure code, refs PM-24373 (#6353) --- .../SendOrganizationInvitesCommand.cs | 6 +--- src/Core/Constants.cs | 1 - .../Models/Mail/OrganizationInvitesInfo.cs | 6 ---- .../Mail/OrganizationUserInvitedViewModel.cs | 34 ------------------- .../Implementations/HandlebarsMailService.cs | 26 +++++--------- 5 files changed, 10 insertions(+), 63 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index 69b968d438..cd5066d11b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -22,8 +22,7 @@ public class SendOrganizationInvitesCommand( IPolicyRepository policyRepository, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, - IMailService mailService, - IFeatureService featureService) : ISendOrganizationInvitesCommand + IMailService mailService) : ISendOrganizationInvitesCommand { public async Task SendInvitesAsync(SendInvitesRequest request) { @@ -72,15 +71,12 @@ 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 43bba121df..025871440f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -135,7 +135,6 @@ 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/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs index c31e00c184..af53f23a8a 100644 --- a/src/Core/Models/Mail/OrganizationInvitesInfo.cs +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -15,7 +15,6 @@ public class OrganizationInvitesInfo bool orgSsoLoginRequiredPolicyEnabled, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs, Dictionary orgUserHasExistingUserDict, - bool isSubjectFeatureEnabled = false, bool initOrganization = false ) { @@ -30,8 +29,6 @@ public class OrganizationInvitesInfo OrgUserTokenPairs = orgUserTokenPairs; OrgUserHasExistingUserDict = orgUserHasExistingUserDict; - - IsSubjectFeatureEnabled = isSubjectFeatureEnabled; } public string OrganizationName { get; } @@ -40,9 +37,6 @@ public class OrganizationInvitesInfo public bool OrgSsoEnabled { get; } 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 6e5504c73a..669887c4b6 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -20,40 +20,6 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel OrganizationUser orgUser, ExpiringToken expiringToken, 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 ", - 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 = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id] - }; - } - - 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!"; diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 89a613b7ed..9728c2e727 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -355,11 +355,8 @@ public class HandlebarsMailService : IMailService { Debug.Assert(orgUserTokenPair.OrgUser.Email is not null); - var orgUserInviteViewModel = orgInvitesInfo.IsSubjectFeatureEnabled - ? OrganizationUserInvitedViewModel.CreateFromInviteInfo_v2( - orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings) - : OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, - orgUserTokenPair.Token, _globalSettings); + var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, + orgUserTokenPair.Token, _globalSettings); return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); }); @@ -369,20 +366,15 @@ public class HandlebarsMailService : IMailService MailQueueMessage CreateMessage(string email, OrganizationUserInvitedViewModel model) { - var subject = $"Join {model.OrganizationName}"; + ArgumentNullException.ThrowIfNull(model); - if (orgInvitesInfo.IsSubjectFeatureEnabled) + var subject = model! switch { - 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" - }; - } + { 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); From d2c2ae5b4d694087d0873b553820808fdf05add9 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:30:05 -0700 Subject: [PATCH 095/163] fix(invalid-auth-request-approvals): Auth/[PM-3387] Better Error Handling for Invalid Auth Request Approval (#6264) If a user approves an invalid auth request, on the Requesting Device they currently they get stuck on the `LoginViaAuthRequestComponent` with a spinning wheel. This PR makes it so that when an Approving Device attempts to approve an invalid auth request, the Approving Device receives an error toast and the `UpdateAuthRequestAsync()` operation is blocked. --- .../Controllers/AuthRequestsController.cs | 30 +++++ .../AuthRequestsControllerTests.cs | 114 +++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index c62b817905..e4a9027f20 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -102,7 +102,37 @@ public class AuthRequestsController( public async Task Put(Guid id, [FromBody] AuthRequestUpdateRequestModel model) { var userId = _userService.GetProperUserId(User).Value; + + // If the Approving Device is attempting to approve a request, validate the approval + if (model.RequestApproved == true) + { + await ValidateApprovalOfMostRecentAuthRequest(id, userId); + } + var authRequest = await _authRequestService.UpdateAuthRequestAsync(id, userId, model); return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } + + private async Task ValidateApprovalOfMostRecentAuthRequest(Guid id, Guid userId) + { + // Get the current auth request to find the device identifier + var currentAuthRequest = await _authRequestService.GetAuthRequestAsync(id, userId); + if (currentAuthRequest == null) + { + throw new NotFoundException(); + } + + // Get all pending auth requests for this user (returns most recent per device) + var pendingRequests = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId); + + // Find the most recent request for the same device + var mostRecentForDevice = pendingRequests + .FirstOrDefault(pendingRequest => pendingRequest.RequestDeviceIdentifier == currentAuthRequest.RequestDeviceIdentifier); + + var isMostRecentRequestForDevice = mostRecentForDevice?.Id == id; + if (!isMostRecentRequestForDevice) + { + throw new BadRequestException("This request is no longer valid. Make sure to approve the most recent request."); + } + } } diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs index 1b8e7aba8e..fc7eb0d93b 100644 --- a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -222,7 +222,7 @@ public class AuthRequestsControllerTests } [Theory, BitAutoData] - public async Task Put_ReturnsAuthRequest( + public async Task Put_WithRequestNotApproved_ReturnsAuthRequest( SutProvider sutProvider, User user, AuthRequestUpdateRequestModel requestModel, @@ -230,6 +230,7 @@ public class AuthRequestsControllerTests { // Arrange SetBaseServiceUri(sutProvider); + requestModel.RequestApproved = false; // Not an approval, so validation should be skipped sutProvider.GetDependency() .GetProperUserId(Arg.Any()) @@ -248,6 +249,117 @@ public class AuthRequestsControllerTests Assert.IsType(result); } + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_ValidatesAndReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + AuthRequest updatedAuthRequest, + List pendingRequests) + { + // Arrange + SetBaseServiceUri(sutProvider); + requestModel.RequestApproved = true; // Approval triggers validation + currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123"; + + // Setup pending requests - make the current request the most recent for its device + var mostRecentForDevice = new PendingAuthRequestDetails(currentAuthRequest, Guid.NewGuid()); + pendingRequests.Add(mostRecentForDevice); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Setup validation dependencies + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + sutProvider.GetDependency() + .UpdateAuthRequestAsync(currentAuthRequest.Id, user.Id, requestModel) + .Returns(updatedAuthRequest); + + // Act + var result = await sutProvider.Sut + .Put(currentAuthRequest.Id, requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_CurrentAuthRequestNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + Guid authRequestId) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Current auth request not found + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequestId, user.Id) + .Returns((AuthRequest)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(authRequestId, requestModel)); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_NotMostRecentForDevice_ThrowsBadRequestException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + List pendingRequests) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123"; + + // Setup pending requests - make a different request the most recent for the same device + var differentAuthRequest = new AuthRequest + { + Id = Guid.NewGuid(), // Different ID than current request + RequestDeviceIdentifier = currentAuthRequest.RequestDeviceIdentifier, + UserId = user.Id, + Type = AuthRequestType.AuthenticateAndUnlock, + CreationDate = DateTime.UtcNow + }; + var mostRecentForDevice = new PendingAuthRequestDetails(differentAuthRequest, Guid.NewGuid()); + pendingRequests.Add(mostRecentForDevice); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(currentAuthRequest.Id, requestModel)); + + Assert.Equal("This request is no longer valid. Make sure to approve the most recent request.", exception.Message); + } + private void SetBaseServiceUri(SutProvider sutProvider) { sutProvider.GetDependency() From 14b307c15b6af5fc05a86110d3fea7b6ca548387 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:26:22 -0500 Subject: [PATCH 096/163] [PM-25205] Don't respond with a tax ID warning for US customers (#6310) * Don't respond with a Tax ID warning for US customers * Only show provider tax ID warning for non-US based providers --- .../Queries/GetProviderWarningsQuery.cs | 8 +- .../Queries/GetProviderWarningsQueryTests.cs | 79 +++- .../Queries/GetOrganizationWarningsQuery.cs | 6 + .../GetOrganizationWarningsQueryTests.cs | 441 +++++++++++++++++- 4 files changed, 496 insertions(+), 38 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs index 9392c285e0..cc77797307 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -10,6 +10,7 @@ using Stripe.Tax; namespace Bit.Commercial.Core.Billing.Providers.Queries; +using static Bit.Core.Constants; using static StripeConstants; using SuspensionWarning = ProviderWarnings.SuspensionWarning; using TaxIdWarning = ProviderWarnings.TaxIdWarning; @@ -61,6 +62,11 @@ public class GetProviderWarningsQuery( Provider provider, Customer customer) { + if (customer.Address?.Country == CountryAbbreviations.UnitedStates) + { + return null; + } + if (!currentContext.ProviderProviderAdmin(provider.Id)) { return null; @@ -75,7 +81,7 @@ public class GetProviderWarningsQuery( .SelectMany(registrations => registrations.Data); // Find the matching registration for the customer - var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country); + var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country); // If we're not registered in their country, we don't need a warning if (registration == null) diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs index f199c44924..a7f896ef7a 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -58,7 +58,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -90,7 +90,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -124,7 +124,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -158,7 +158,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -191,7 +191,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -219,7 +219,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -227,7 +227,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "CA" }] + Data = [new Registration { Country = "GB" }] }); var response = await sutProvider.Sut.Run(provider); @@ -252,7 +252,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -260,7 +260,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -291,7 +291,7 @@ public class GetProviderWarningsQueryTests { Data = [new TaxId { Verification = null }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -299,7 +299,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -333,7 +333,7 @@ public class GetProviderWarningsQueryTests } }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -341,7 +341,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -378,7 +378,7 @@ public class GetProviderWarningsQueryTests } }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -386,7 +386,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -423,7 +423,7 @@ public class GetProviderWarningsQueryTests } }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -431,7 +431,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -498,6 +498,44 @@ public class GetProviderWarningsQueryTests Status = SubscriptionStatus.Unpaid, CancelAt = cancelAt, Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "add_payment_method", + TaxId.Type: "tax_id_missing" + }); + Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_USCustomer_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer { TaxIds = new StripeList { Data = [] }, Address = new Address { Country = "US" } @@ -513,11 +551,6 @@ public class GetProviderWarningsQueryTests var response = await sutProvider.Sut.Run(provider); - Assert.True(response is - { - Suspension.Resolution: "add_payment_method", - TaxId.Type: "tax_id_missing" - }); - Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + Assert.Null(response!.TaxId); } } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 312623ffa5..f33814f1cf 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -15,6 +15,7 @@ using Stripe.Tax; namespace Bit.Core.Billing.Organizations.Queries; +using static Core.Constants; using static StripeConstants; using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning; using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning; @@ -232,6 +233,11 @@ public class GetOrganizationWarningsQuery( Customer customer, Provider? provider) { + if (customer.Address?.Country == CountryAbbreviations.UnitedStates) + { + return null; + } + var productTier = organization.PlanType.GetProductTier(); // Only business tier customers can have tax IDs diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index eefda06149..5234d500d1 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -13,11 +14,14 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ReturnsExtensions; using Stripe; +using Stripe.Tax; using Stripe.TestHelpers; using Xunit; namespace Bit.Core.Test.Billing.Organizations.Queries; +using static StripeConstants; + [SutProviderCustomize] public class GetOrganizationWarningsQueryTests { @@ -57,7 +61,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Trialing, + Status = SubscriptionStatus.Trialing, TrialEnd = now.AddDays(7), Customer = new Customer { @@ -95,7 +99,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Trialing, + Status = SubscriptionStatus.Trialing, TrialEnd = now.AddDays(7), Customer = new Customer { @@ -142,7 +146,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Customer = new Customer { InvoiceSettings = new CustomerInvoiceSettings(), @@ -170,7 +174,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid + Customer = new Customer(), + Status = SubscriptionStatus.Unpaid }); sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) @@ -197,7 +202,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid + Customer = new Customer(), + Status = SubscriptionStatus.Unpaid }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); @@ -223,7 +229,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Canceled + Customer = new Customer(), + Status = SubscriptionStatus.Canceled }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); @@ -249,7 +256,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid + Customer = new Customer(), + Status = SubscriptionStatus.Unpaid }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); @@ -275,8 +283,9 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, - Status = StripeConstants.SubscriptionStatus.Active, + CollectionMethod = CollectionMethod.SendInvoice, + Customer = new Customer(), + Status = SubscriptionStatus.Active, CurrentPeriodEnd = now.AddDays(10), TestClock = new TestClock { @@ -313,11 +322,12 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, - Status = StripeConstants.SubscriptionStatus.Active, + CollectionMethod = CollectionMethod.SendInvoice, + Customer = new Customer(), + Status = SubscriptionStatus.Active, LatestInvoice = new Invoice { - Status = StripeConstants.InvoiceStatus.Open, + Status = InvoiceStatus.Open, DueDate = now.AddDays(30), Created = now }, @@ -360,8 +370,9 @@ public class GetOrganizationWarningsQueryTests .Returns(new Subscription { Id = subscriptionId, - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, - Status = StripeConstants.SubscriptionStatus.PastDue, + CollectionMethod = CollectionMethod.SendInvoice, + Customer = new Customer(), + Status = SubscriptionStatus.PastDue, TestClock = new TestClock { FrozenTime = now @@ -390,4 +401,406 @@ public class GetOrganizationWarningsQueryTests Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate); } + + [Theory, BitAutoData] + public async Task Run_USCustomer_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "US" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_FreeCustomer_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.Free; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NotOwner_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(false); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_HasProvider_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider()); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NoRegistrationInCountry_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "GB" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdWarning_Missing( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + TaxId.Type: "tax_id_missing" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdWarning_PendingVerification( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.EnterpriseAnnually; + + var taxId = new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Pending + } + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + TaxId.Type: "tax_id_pending_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdWarning_FailedVerification( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var taxId = new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Unverified + } + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + TaxId.Type: "tax_id_failed_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_VerifiedTaxId_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var taxId = new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Verified + } + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NullVerification_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var taxId = new TaxId + { + Verification = null + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } } From 3ac3b8c8d984eaf5fee59be35bb2bd4cb0c8ac61 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:27:12 -0500 Subject: [PATCH 097/163] Remove FF (#6302) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 025871440f..cfc85f8164 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,7 +168,6 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; 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"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; From d384c0cfe60ec02226479d3cde200e4d785a7e50 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 19 Sep 2025 16:17:32 -0400 Subject: [PATCH 098/163] [PM-7730] Deprecate type-specific cipher properties in favor of opaque Data string (#6354) * Marked structured fields as obsolete and add Data field to the request model * Fixed lint issues * Deprecated properties * Changed to 1mb --- .../Models/Request/CipherRequestModel.cs | 74 +++++++++++++------ .../Models/Response/CipherResponseModel.cs | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 467be6e356..b0589a62f9 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -7,8 +7,6 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; -using NS = Newtonsoft.Json; -using NSL = Newtonsoft.Json.Linq; namespace Bit.Api.Vault.Models.Request; @@ -40,11 +38,26 @@ public class CipherRequestModel // TODO: Rename to Attachments whenever the above is finally removed. public Dictionary Attachments2 { get; set; } + [Obsolete("Use Data instead.")] public CipherLoginModel Login { get; set; } + + [Obsolete("Use Data instead.")] public CipherCardModel Card { get; set; } + + [Obsolete("Use Data instead.")] public CipherIdentityModel Identity { get; set; } + + [Obsolete("Use Data instead.")] public CipherSecureNoteModel SecureNote { get; set; } + + [Obsolete("Use Data instead.")] public CipherSSHKeyModel SSHKey { get; set; } + + /// + /// JSON string containing cipher-specific data + /// + [StringLength(500000)] + public string Data { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; public DateTime? ArchivedDate { get; set; } @@ -73,29 +86,42 @@ public class CipherRequestModel public Cipher ToCipher(Cipher existingCipher) { - switch (existingCipher.Type) + // If Data field is provided, use it directly + if (!string.IsNullOrWhiteSpace(Data)) { - case CipherType.Login: - var loginObj = NSL.JObject.FromObject(ToCipherLoginData(), - new NS.JsonSerializer { NullValueHandling = NS.NullValueHandling.Ignore }); - // TODO: Switch to JsonNode in .NET 6 https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-use-dom-utf8jsonreader-utf8jsonwriter?pivots=dotnet-6-0 - loginObj[nameof(CipherLoginData.Uri)]?.Parent?.Remove(); - existingCipher.Data = loginObj.ToString(NS.Formatting.None); - break; - case CipherType.Card: - existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.Identity: - existingCipher.Data = JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.SecureNote: - existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.SSHKey: - existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull); - break; - default: - throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); + existingCipher.Data = Data; + } + else + { + // Fallback to structured fields + switch (existingCipher.Type) + { + case CipherType.Login: + var loginData = ToCipherLoginData(); + var loginJson = JsonSerializer.Serialize(loginData, JsonHelpers.IgnoreWritingNull); + var loginObj = JsonDocument.Parse(loginJson); + var loginDict = JsonSerializer.Deserialize>(loginJson); + loginDict?.Remove(nameof(CipherLoginData.Uri)); + + existingCipher.Data = JsonSerializer.Serialize(loginDict, JsonHelpers.IgnoreWritingNull); + break; + case CipherType.Card: + existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.Identity: + existingCipher.Data = + JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.SecureNote: + existingCipher.Data = + JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.SSHKey: + existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull); + break; + default: + throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); + } } existingCipher.Reprompt = Reprompt; diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 3e4e8da512..dfacc1a551 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -24,6 +24,7 @@ public class CipherMiniResponseModel : ResponseModel Id = cipher.Id; Type = cipher.Type; + Data = cipher.Data; CipherData cipherData; switch (cipher.Type) @@ -31,30 +32,25 @@ public class CipherMiniResponseModel : ResponseModel case CipherType.Login: var loginData = JsonSerializer.Deserialize(cipher.Data); cipherData = loginData; - Data = loginData; Login = new CipherLoginModel(loginData); break; case CipherType.SecureNote: var secureNoteData = JsonSerializer.Deserialize(cipher.Data); - Data = secureNoteData; cipherData = secureNoteData; SecureNote = new CipherSecureNoteModel(secureNoteData); break; case CipherType.Card: var cardData = JsonSerializer.Deserialize(cipher.Data); - Data = cardData; cipherData = cardData; Card = new CipherCardModel(cardData); break; case CipherType.Identity: var identityData = JsonSerializer.Deserialize(cipher.Data); - Data = identityData; cipherData = identityData; Identity = new CipherIdentityModel(identityData); break; case CipherType.SSHKey: var sshKeyData = JsonSerializer.Deserialize(cipher.Data); - Data = sshKeyData; cipherData = sshKeyData; SSHKey = new CipherSSHKeyModel(sshKeyData); break; @@ -80,15 +76,33 @@ public class CipherMiniResponseModel : ResponseModel public Guid Id { get; set; } public Guid? OrganizationId { get; set; } public CipherType Type { get; set; } - public dynamic Data { get; set; } + public string Data { get; set; } + + [Obsolete("Use Data instead.")] public string Name { get; set; } + + [Obsolete("Use Data instead.")] public string Notes { get; set; } + + [Obsolete("Use Data instead.")] public CipherLoginModel Login { get; set; } + + [Obsolete("Use Data instead.")] public CipherCardModel Card { get; set; } + + [Obsolete("Use Data instead.")] public CipherIdentityModel Identity { get; set; } + + [Obsolete("Use Data instead.")] public CipherSecureNoteModel SecureNote { get; set; } + + [Obsolete("Use Data instead.")] public CipherSSHKeyModel SSHKey { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable Fields { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable PasswordHistory { get; set; } public IEnumerable Attachments { get; set; } public bool OrganizationUseTotp { get; set; } From dc2828291b0de886b36d2aebcd18d11ae8f32bab Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 22 Sep 2025 15:02:24 +0000 Subject: [PATCH 099/163] Bumped version to 2025.9.2 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9038c8d95d..71303d3529 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.1 + 2025.9.2 Bit.$(MSBuildProjectName) enable From fe7e96eb6aeaa42ac2fe7af9c3bcf74258c52abf Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 22 Sep 2025 10:36:19 -0500 Subject: [PATCH 100/163] PM-25870 Activity tab feature flag (#6360) --- src/Core/Constants.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cfc85f8164..f596cdefee 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -233,6 +233,9 @@ public static class FeatureFlagKeys /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; + /* DIRT Team */ + public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) From 0b6b93048b4b57d51183e2aa4dc5a1e0e9a51f8a Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:05:16 -0500 Subject: [PATCH 101/163] [PM-25373] Add feature flag (#6358) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f596cdefee..96ee509db1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -184,6 +184,7 @@ public static class FeatureFlagKeys 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"; + public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; From 8c238ce08db8e5b227ebdc9a980b99445b7ed140 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 22 Sep 2025 13:46:35 -0400 Subject: [PATCH 102/163] fix: adjust permissions of repo management workflow (#6130) - Specify permissions needed for the repo_management job - Add required permissions (actions: read, contents: write, id-token: write, pull-requests: write) to the move_edd_db_scripts job --- .github/workflows/repository-management.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index ad80d5864c..67e1d8a926 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,7 +22,9 @@ on: required: false type: string -permissions: {} +permissions: + pull-requests: write + contents: write jobs: setup: @@ -231,5 +233,10 @@ jobs: move_edd_db_scripts: name: Move EDD database scripts needs: cut_branch + permissions: + actions: read + contents: write + id-token: write + pull-requests: write uses: ./.github/workflows/_move_edd_db_scripts.yml secrets: inherit From ed5e4271df54e54dc271905cc10efefb967eaaa7 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 22 Sep 2025 13:51:36 -0400 Subject: [PATCH 103/163] [PM-25123] Remove VerifyBankAsync Code (#6355) * refactor: remove VerifyBankAsync from interface and implementation * refactor: remove controller endpoint --- .../Controllers/OrganizationsController.cs | 12 ------ .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 43 ------------------- 3 files changed, 56 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 977b20bdfb..5494c5a90e 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -211,18 +211,6 @@ public class OrganizationsController( return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - [HttpPost("{id:guid}/verify-bank")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model) - { - if (!await currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value); - } - [HttpPost("{id}/cancel")] public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) { diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 94df74afdf..f509ac8358 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -18,7 +18,6 @@ public interface IOrganizationService Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); 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); Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 57eb4f51de..1b52ad8cff 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -325,49 +325,6 @@ public class OrganizationService : IOrganizationService return paymentIntentClientSecret; } - public async Task VerifyBankAsync(Guid organizationId, int amount1, int amount2) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) - { - throw new GatewayException("Not a gateway customer."); - } - - var bankService = new BankAccountService(); - var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, - new CustomerGetOptions { Expand = new List { "sources" } }); - if (customer == null) - { - throw new GatewayException("Cannot find customer."); - } - - var bankAccount = customer.Sources - .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; - if (bankAccount == null) - { - throw new GatewayException("Cannot find an unverified bank account."); - } - - try - { - var result = await bankService.VerifyAsync(organization.GatewayCustomerId, bankAccount.Id, - new BankAccountVerifyOptions { Amounts = new List { amount1, amount2 } }); - if (result.Status != "verified") - { - throw new GatewayException("Unable to verify account."); - } - } - catch (StripeException e) - { - throw new GatewayException(e.Message); - } - } - public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate) { var org = await GetOrgById(organizationId); From c6f5d5e36e830642c6e028732aa66cb6a9583ad2 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Mon, 22 Sep 2025 15:39:15 -0400 Subject: [PATCH 104/163] [PM-25986] Add server side enum type for AutotypeDefaultSetting policy (#6356) * PM-25986 Add server side enum type for AutotypeDefaultSetting policy * Update PolicyType.cs remove space --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index f038faaf0b..452fbcce01 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -19,6 +19,7 @@ public enum PolicyType : byte RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, UriMatchDefaults = 16, + AutotypeDefaultSetting = 17, } public static class PolicyTypeExtensions @@ -48,6 +49,7 @@ public static class PolicyTypeExtensions PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", PolicyType.UriMatchDefaults => "URI match defaults", + PolicyType.AutotypeDefaultSetting => "Autotype default setting", }; } } From 3b54fea309fc9612389a77785fd7f5470366370a Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Tue, 23 Sep 2025 06:38:22 -0400 Subject: [PATCH 105/163] [PM-22696] send enumeration protection (#6352) * feat: add static enumeration helper class * test: add enumeration helper class unit tests * feat: implement NeverAuthenticateValidator * test: unit and integration tests SendNeverAuthenticateValidator * test: use static class for common integration test setup for Send Access unit and integration tests * test: update tests to use static helper --- src/Core/Settings/GlobalSettings.cs | 4 + .../Utilities/EnumerationProtectionHelpers.cs | 36 +++ .../SendAccess/SendAccessConstants.cs | 22 +- .../SendAccess/SendAccessGrantValidator.cs | 37 +-- .../SendNeverAuthenticateRequestValidator.cs | 87 ++++++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../EnumerationProtectionHelpersTests.cs | 230 ++++++++++++++ ...endAccessGrantValidatorIntegrationTests.cs | 53 +--- .../SendAccess/SendAccessTestUtilities.cs | 45 +++ ...EmailOtpReqestValidatorIntegrationTests.cs | 54 +--- ...ndNeverAuthenticateRequestValidatorTest.cs | 168 +++++++++++ ...asswordRequestValidatorIntegrationTests.cs | 45 +-- .../ResourceOwnerPasswordValidatorTests.cs | 2 +- .../SendAccessGrantValidatorTests.cs | 63 +--- .../SendAccess/SendAccessTestUtilities.cs | 50 ++++ .../SendAccess/SendConstantsSnapshotTests.cs | 6 +- .../SendEmailOtpRequestValidatorTests.cs | 49 +-- .../SendNeverAuthenticateValidatorTests.cs | 280 ++++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 47 +-- 19 files changed, 989 insertions(+), 290 deletions(-) create mode 100644 src/Core/Utilities/EnumerationProtectionHelpers.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs create mode 100644 test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendAccessGrantValidatorIntegrationTests.cs (81%) create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendEmailOtpReqestValidatorIntegrationTests.cs (79%) create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendPasswordRequestValidatorIntegrationTests.cs (80%) rename test/Identity.IntegrationTest/RequestValidation/{ => VaultAccess}/ResourceOwnerPasswordValidatorTests.cs (99%) create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index c6c96cffb9..107fd29236 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -92,6 +92,10 @@ public class GlobalSettings : IGlobalSettings public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } + /// + /// This Hash Key is used to prevent enumeration attacks against the Send Access feature. + /// + public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) diff --git a/src/Core/Utilities/EnumerationProtectionHelpers.cs b/src/Core/Utilities/EnumerationProtectionHelpers.cs new file mode 100644 index 0000000000..b27c36e03a --- /dev/null +++ b/src/Core/Utilities/EnumerationProtectionHelpers.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Bit.Core.Utilities; + +public static class EnumerationProtectionHelpers +{ + /// + /// Use this method to get a consistent int result based on the inputString that is in the range. + /// The same inputString will always return the same index result based on range input. + /// + /// Key used to derive the HMAC hash. Use a different key for each usage for optimal security + /// The string to derive an index result + /// The range of possible index values + /// An int between 0 and range - 1 + public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range) + { + if (hmacKey == null || range <= 0 || hmacKey.Length == 0) + { + return 0; + } + else + { + // Compute the HMAC hash of the salt + var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant()); + using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey); + var hmacHash = hmac.ComputeHash(hmacMessage); + // Convert the hash to a number + var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); + var hashFirst8Bytes = hashHex[..16]; + var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); + // Find the default KDF value for this hash number + var hashIndex = (int)(Math.Abs(hashNumber) % range); + return hashIndex; + } + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index 17ec387411..1f5bfba244 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -34,18 +34,18 @@ public static class SendAccessConstants public const string Otp = "otp"; } - public static class GrantValidatorResults + public static class SendIdGuidValidatorResults { /// - /// The sendId is valid and the request is well formed. Not returned in any response. + /// The in the request is a valid GUID and the request is well formed. Not returned in any response. /// public const string ValidSendGuid = "valid_send_guid"; /// - /// The sendId is missing from the request. + /// The is missing from the request. /// public const string SendIdRequired = "send_id_required"; /// - /// The sendId is invalid, does not match a known send. + /// The is invalid, does not match a known send. /// public const string InvalidSendId = "send_id_invalid"; } @@ -53,11 +53,11 @@ public static class SendAccessConstants public static class PasswordValidatorResults { /// - /// The passwordHashB64 does not match the send's password hash. + /// The does not match the send's password hash. /// public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid"; /// - /// The passwordHashB64 is missing from the request. + /// The is missing from the request. /// public const string RequestPasswordIsRequired = "password_hash_b64_required"; } @@ -105,4 +105,14 @@ public static class SendAccessConstants { public const string Subject = "Your Bitwarden Send verification code is {0}"; } + + /// + /// We use these static strings to help guide the enumeration protection logic. + /// + public static class EnumerationProtection + { + public const string Guid = "guid"; + public const string Password = "password"; + public const string Email = "email"; + } } diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index d9ae946d16..101c6952f3 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, + ISendAuthenticationMethodValidator _sendNeverAuthenticateValidator, ISendAuthenticationMethodValidator _sendPasswordRequestValidator, ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, - IFeatureService _featureService) -: IExtensionGrantValidator + IFeatureService _featureService) : IExtensionGrantValidator { string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; - private static readonly Dictionary - _sendGrantValidatorErrorDescriptions = new() + private static readonly Dictionary _sendGrantValidatorErrorDescriptions = new() { - { SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, - { SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } + { SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, + { SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } }; - public async Task ValidateAsync(ExtensionGrantValidationContext context) { // Check the feature flag @@ -38,7 +36,7 @@ public class SendAccessGrantValidator( } var (sendIdGuid, result) = GetRequestSendId(context); - if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid) + if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid) { context.Result = BuildErrorResult(result); return; @@ -49,15 +47,10 @@ public class SendAccessGrantValidator( switch (method) { - case NeverAuthenticate: + case NeverAuthenticate never: // null send scenario. - // TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances). - // We should only map to password or email + OTP protected. - // If user submits password guess for a falsely protected send, then we will return invalid password. - // If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email. - context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId); + context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid); return; - case NotAuthenticated: // automatically issue access token context.Result = BuildBaseSuccessResult(sendIdGuid); @@ -90,7 +83,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.SendIdRequired); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); } // the send_id is not null so the request is the correct shape, so we will attempt to parse it try @@ -100,20 +93,20 @@ public class SendAccessGrantValidator( // Guid.Empty indicates an invalid send_id return invalid grant if (sendGuid == Guid.Empty) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } - return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid); + return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); } catch { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } } /// /// Builds an error result for the specified error type. /// - /// This error is a constant string from + /// This error is a constant string from /// The error result. private static GrantValidationResult BuildErrorResult(string error) { @@ -125,12 +118,12 @@ public class SendAccessGrantValidator( return error switch { // Request is the wrong shape - SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult( + SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), // Request is correct shape but data is bad - SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult( TokenRequestErrors.InvalidGrant, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs new file mode 100644 index 0000000000..36e033360f --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs @@ -0,0 +1,87 @@ +using System.Text; +using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +/// +/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result. +/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures +/// that the same error is always returned for the same SendId. +/// +/// We need access to a hash key to generate the error index. +public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator +{ + private readonly string[] _errorOptions = + [ + SendAccessConstants.EnumerationProtection.Guid, + SendAccessConstants.EnumerationProtection.Password, + SendAccessConstants.EnumerationProtection.Email + ]; + + public Task ValidateRequestAsync( + ExtensionGrantValidationContext context, + NeverAuthenticate authMethod, + Guid sendId) + { + var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length); + var request = context.Request.Raw; + var errorType = neverAuthenticateError; + + switch (neverAuthenticateError) + { + case SendAccessConstants.EnumerationProtection.Guid: + errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + break; + case SendAccessConstants.EnumerationProtection.Email: + var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null; + errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid + : SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + break; + case SendAccessConstants.EnumerationProtection.Password: + var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null; + errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch + : SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + break; + } + + return Task.FromResult(BuildErrorResult(errorType)); + } + + private static GrantValidationResult BuildErrorResult(string errorType) + { + // Create error response with custom response data + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, errorType } + }; + + var requestError = errorType switch + { + SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest, + SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest, + _ => TokenRequestErrors.InvalidGrant + }; + + return new GrantValidationResult(requestError, errorType, customResponse); + } + + private string GetErrorIndex(Guid sendId, int range) + { + var salt = sendId.ToString(); + byte[] hmacKey = []; + if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey)) + { + hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey); + } + + var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + return _errorOptions[index]; + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 9d062e5c06..e9056d030e 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); services.AddTransient, SendEmailOtpRequestValidator>(); + services.AddTransient, SendNeverAuthenticateRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs new file mode 100644 index 0000000000..68ac8af5d0 --- /dev/null +++ b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs @@ -0,0 +1,230 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EnumerationProtectionHelpersTests +{ + #region GetIndexForInputHash Tests + + [Fact] + public void GetIndexForInputHash_NullHmacKey_ReturnsZero() + { + // Arrange + byte[] hmacKey = null; + var salt = "test@example.com"; + var range = 10; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_ZeroRange_ReturnsZero() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = 0; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_NegativeRange_ReturnsZero() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = -5; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_ValidInputs_ReturnsConsistentResult() + { + // Arrange + var hmacKey = Encoding.UTF8.GetBytes("test-key-12345678901234567890123456789012"); + var salt = "test@example.com"; + var range = 10; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_SameInputSameKey_AlwaysReturnsSameResult() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "consistent@example.com"; + var range = 100; + + // Act - Call multiple times + var results = new int[10]; + for (var i = 0; i < 10; i++) + { + results[i] = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + } + + // Assert - All results should be identical + Assert.All(results, result => Assert.Equal(results[0], result)); + Assert.All(results, result => Assert.InRange(result, 0, range - 1)); + } + + [Fact] + public void GetIndexForInputHash_DifferentInputsSameKey_ReturnsDifferentResults() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt1 = "user1@example.com"; + var salt2 = "user2@example.com"; + var range = 100; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt1, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt2, range); + + // Assert + Assert.NotEqual(result1, result2); + Assert.InRange(result1, 0, range - 1); + Assert.InRange(result2, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_DifferentKeysSameInput_ReturnsDifferentResults() + { + // Arrange + var hmacKey1 = RandomNumberGenerator.GetBytes(32); + var hmacKey2 = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = 100; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey1, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey2, salt, range); + + // Assert + Assert.NotEqual(result1, result2); + Assert.InRange(result1, 0, range - 1); + Assert.InRange(result2, 0, range - 1); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + [InlineData(10)] + [InlineData(100)] + [InlineData(1000)] + public void GetIndexForInputHash_VariousRanges_ReturnsValidIndex(int range) + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.InRange(result, 0, range - 1); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GetIndexForInputHash_EmptyString_HandlesGracefully(string salt) + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, 10); + + // Assert + Assert.InRange(result, 0, 9); + } + + [Fact] + public void GetIndexForInputHash_NullInput_ThrowsException() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + string salt = null; + var range = 10; + + // Act & Assert + Assert.Throws(() => + EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range)); + } + + [Fact] + public void GetIndexForInputHash_SpecialCharacters_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test+user@example.com!@#$%^&*()"; + var range = 50; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_UnicodeCharacters_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "tëst@éxämplé.cöm"; + var range = 25; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_LongInput_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = new string('a', 1000) + "@example.com"; + var range = 30; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.InRange(result, 0, range - 1); + } + + #endregion +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs similarity index 81% rename from test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs index ca6417d49c..a6dd4b6b38 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs @@ -1,10 +1,8 @@ using Bit.Core; -using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; 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; @@ -13,16 +11,14 @@ using Duende.IdentityServer.Validation; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; // in order to test the default case for the authentication method, we need to create a custom one so we can ensure the // method throws as expected. internal record AnUnknownAuthenticationMethod : SendAuthenticationMethod { } -public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory factory) : IClassFixture +public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory = factory; - [Fact] public async Task SendAccessGrant_FeatureFlagDisabled_ReturnsUnsupportedGrantType() { @@ -39,7 +35,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -70,7 +66,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -125,7 +121,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -154,7 +150,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -183,7 +179,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var error = await client.PostAsync("/connect/token", requestBody); @@ -225,7 +221,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId, "password123"); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, "password123"); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -236,37 +232,4 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory Assert.Contains("access_token", content); Assert.Contains("Bearer", content); } - - private static FormUrlEncodedContent CreateTokenRequestBody( - Guid sendId, - string password = null, - 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(password)) - { - parameters.Add(new(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); - } - - if (!string.IsNullOrEmpty(emailOtp) && !string.IsNullOrEmpty(sendEmail)) - { - parameters.AddRange( - [ - new KeyValuePair("email", sendEmail), - new KeyValuePair("email_otp", emailOtp) - ]); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..7842bf6367 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,45 @@ +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Duende.IdentityModel; + +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; + +public static class SendAccessTestUtilities +{ + public static FormUrlEncodedContent CreateTokenRequestBody( + Guid sendId, + string email = null, + string emailOtp = null, + string password = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + var parameters = new List> + { + new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), + new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send), + new(SendAccessConstants.TokenRequest.SendId, sendIdBase64), + new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), + new("device_type", "10") + }; + + if (!string.IsNullOrEmpty(email)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Email, email)); + } + + if (!string.IsNullOrEmpty(emailOtp)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Otp, emailOtp)); + } + + if (!string.IsNullOrEmpty(password)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs similarity index 79% rename from test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs index 9a097cc061..3c4657653b 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -1,28 +1,16 @@ using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; 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; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; -public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture +public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory; - - public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory) - { - _factory = factory; - } - [Fact] public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest() { @@ -43,7 +31,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture> - { - 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.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs new file mode 100644 index 0000000000..a81b01a293 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs @@ -0,0 +1,168 @@ +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.RequestValidators.SendAccess; +using Bit.IntegrationTestCommon.Factories; +using Duende.IdentityModel; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; + +public class SendNeverAuthenticateRequestValidatorIntegrationTests( + IdentityApplicationFactory _factory) : IClassFixture +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + private static readonly string _testHashKey = "test-key-123456789012345678901234567890"; + // These Guids are static and ensure the correct index for each error type + private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f"); + private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41"); + private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b"); + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_NoParameters_ReturnsInvalidSendId() + { + // Arrange + var client = ConfigureTestHttpClient(_invalidSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_invalidSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + var expectedError = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ReturnsEmailRequired() + { + // Arrange + var client = ConfigureTestHttpClient(_emailSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // should be invalid grant + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + + // Try to compel the invalid email error + var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_WithEmail_ReturnsEmailInvalid() + { + // Arrange + var email = "test@example.com"; + var client = ConfigureTestHttpClient(_emailSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid, email: email); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // should be invalid grant + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + // Try to compel the invalid email error + var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailInvalid; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ReturnsPasswordRequired() + { + // Arrange + var client = ConfigureTestHttpClient(_passwordSendGuid); + + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_WithPassword_ReturnsPasswordInvalid() + { + // Arrange + var password = "test-password-hash"; + + var client = ConfigureTestHttpClient(_passwordSendGuid); + + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid, password: password); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + + var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ConsistentResponse_SameSendId() + { + // Arrange + var client = ConfigureTestHttpClient(_emailSendGuid); + + var requestBody1 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + var requestBody2 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + + // Act + var response1 = await client.PostAsync("/connect/token", requestBody1); + var response2 = await client.PostAsync("/connect/token", requestBody2); + + // Assert + var content1 = await response1.Content.ReadAsStringAsync(); + var content2 = await response2.Content.ReadAsStringAsync(); + + Assert.Equal(content1, content2); + } + + private HttpClient ConfigureTestHttpClient(Guid sendId) + { + _factory.UpdateConfiguration( + "globalSettings:sendDefaultHashKey", _testHashKey); + return _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 NeverAuthenticate()); + services.AddSingleton(sendAuthQuery); + }); + }).CreateClient(); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs similarity index 80% rename from test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs index 856ffe1f6e..5b03a298ed 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs @@ -1,28 +1,17 @@ -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; -using Bit.Core.KeyManagement.Sends; +using Bit.Core.KeyManagement.Sends; 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; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; -public class SendPasswordRequestValidatorIntegrationTests : IClassFixture +public class SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory; - - public SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory factory) - { - _factory = factory; - } - [Fact] public async Task SendAccess_PasswordProtectedSend_ValidPassword_ReturnsAccessToken() { @@ -54,7 +43,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", "10") - }; - - if (passwordHash != null) - { - parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs similarity index 99% rename from test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs rename to test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs index 4d243906af..91123b3a60 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs @@ -12,7 +12,7 @@ using Bit.Test.Common.Helpers; using Microsoft.AspNetCore.Identity; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.VaultAccess; public class ResourceOwnerPasswordValidatorTests : IClassFixture { diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index 017ad70354..59d8dee2e2 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,12 +1,8 @@ -using System.Collections.Specialized; -using Bit.Core; +using Bit.Core; using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; 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.Test.Common.AutoFixture; @@ -81,7 +77,7 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); tokenRequest.GrantType = CustomGrantTypes.SendAccess; - tokenRequest.Raw = CreateTokenRequestBody(Guid.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(Guid.Empty); // To preserve the CreateTokenRequestBody method for more general usage we over write the sendId tokenRequest.Raw.Set(SendAccessConstants.TokenRequest.SendId, "invalid-guid-format"); @@ -118,7 +114,9 @@ public class SendAccessGrantValidatorTests public async Task ValidateAsync_NeverAuthenticateMethod_ReturnsInvalidGrant( [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, SutProvider sutProvider, - Guid sendId) + NeverAuthenticate neverAuthenticate, + Guid sendId, + GrantValidationResult expectedResult) { // Arrange var context = SetupTokenRequest( @@ -128,14 +126,20 @@ public class SendAccessGrantValidatorTests sutProvider.GetDependency() .GetAuthenticationMethod(sendId) - .Returns(new NeverAuthenticate()); + .Returns(neverAuthenticate); + + sutProvider.GetDependency>() + .ValidateRequestAsync(context, neverAuthenticate, sendId) + .Returns(expectedResult); // Act await sutProvider.Sut.ValidateAsync(context); // Assert - Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); - Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription); + Assert.Equal(expectedResult, context.Result); + await sutProvider.GetDependency>() + .Received(1) + .ValidateRequestAsync(context, neverAuthenticate, sendId); } [Theory, BitAutoData] @@ -264,7 +268,7 @@ public class SendAccessGrantValidatorTests public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); @@ -289,44 +293,9 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); request.GrantType = CustomGrantTypes.SendAccess; - request.Raw = CreateTokenRequestBody(sendId); + request.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); context.Request = request; return context; } - - private static NameValueCollection CreateTokenRequestBody( - Guid sendId, - string passwordHash = null, - 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 }, - { "deviceType", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash); - } - - 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/SendAccessTestUtilities.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..b05380902a --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,50 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Duende.IdentityModel; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +public static class SendAccessTestUtilities +{ + public static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + string sendEmail = null, + string otpCode = null, + 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 (sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); + } + + if (otpCode != null && sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); + } + + 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/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs index 95a0a6675b..96a097a53c 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -31,9 +31,9 @@ public class SendConstantsSnapshotTests 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); + Assert.Equal("valid_send_guid", SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); + Assert.Equal("send_id_required", SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); + Assert.Equal("send_id_invalid", SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } [Fact] diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 70a1585d8b..46f61cb333 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; 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; @@ -28,7 +23,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -61,8 +56,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); - var emailOTP = new EmailOtp(["user@test.dev"]); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -96,7 +90,7 @@ public class SendEmailOtpRequestValidatorTests string generatedToken) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -144,7 +138,7 @@ public class SendEmailOtpRequestValidatorTests string email) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -179,7 +173,7 @@ public class SendEmailOtpRequestValidatorTests string otp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, otp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -231,7 +225,7 @@ public class SendEmailOtpRequestValidatorTests string invalidOtp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, invalidOtp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -278,33 +272,4 @@ public class SendEmailOtpRequestValidatorTests // 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/SendNeverAuthenticateValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs new file mode 100644 index 0000000000..ae0434af83 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs @@ -0,0 +1,280 @@ +using Bit.Core.Tools.Models.Data; +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 Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendNeverAuthenticateRequestValidatorTests +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + private static readonly string _testHashKey = "test-key-123456789012345678901234567890"; + // These Guids are static and ensure the correct index for each error type + private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f"); + private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41"); + private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b"); + + private static readonly NeverAuthenticate _authMethod = new(); + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_GuidErrorSelected_ReturnsInvalidSendId( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_HasEmail_ReturnsEmailInvalid( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string email) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid, sendEmail: email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_NoEmail_ReturnsEmailRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_HasPassword_ReturnsPasswordDoesNotMatch( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string password) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid, passwordHash: password); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_NoPassword_ReturnsPasswordRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_NullHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext { Request = tokenRequest }; + sutProvider.GetDependency().SendDefaultHashKey = null; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmptyHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = ""; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_ConsistentBehavior_SameSendIdSameResult( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "consistent-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + + // Assert + Assert.Equal(result1.ErrorDescription, result2.ErrorDescription); + Assert.Equal(result1.Error, result2.Error); + + var customResponse1 = result1.CustomResponse as Dictionary; + var customResponse2 = result2.CustomResponse as Dictionary; + Assert.Equal(customResponse1[SendAccessConstants.SendAccessError], customResponse2[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_DifferentSendIds_CanReturnDifferentResults( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId1, + Guid sendId2) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId1); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "different-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId1); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId2); + + // Assert - Both should be errors + Assert.True(result1.IsError); + Assert.True(result2.IsError); + + // Both should have valid error types + var validErrors = new[] + { + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired + }; + Assert.Contains(result1.ErrorDescription, validErrors); + Assert.Contains(result2.ErrorDescription, validErrors); + } + + [Fact] + public void Constructor_WithValidGlobalSettings_CreatesInstance() + { + // Arrange + var globalSettings = new Core.Settings.GlobalSettings + { + SendDefaultHashKey = "test-key-123456789012345678901234567890" + }; + + // Act + var validator = new SendNeverAuthenticateRequestValidator(globalSettings); + + // Assert + Assert.NotNull(validator); + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs index e77626d37b..460b033fa7 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures.SendAccess; -using Bit.Core.Enums; 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; @@ -28,7 +23,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { @@ -58,7 +53,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -92,7 +87,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -130,7 +125,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: string.Empty); var context = new ExtensionGrantValidationContext { @@ -163,7 +158,7 @@ public class SendPasswordRequestValidatorTests { // Arrange var whitespacePassword = " "; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: whitespacePassword); var context = new ExtensionGrantValidationContext { @@ -196,7 +191,7 @@ public class SendPasswordRequestValidatorTests // Arrange var firstPassword = "first-password"; var secondPassword = "second-password"; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: [firstPassword, secondPassword]); var context = new ExtensionGrantValidationContext { @@ -229,7 +224,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -268,30 +263,4 @@ public class SendPasswordRequestValidatorTests // 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; - } } From 0b4b605524432431485852024dc5b35e16edd001 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Tue, 23 Sep 2025 15:52:56 +0000 Subject: [PATCH 106/163] Bumped version to 2025.9.3 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 71303d3529..cd1e95d0bd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.2 + 2025.9.3 Bit.$(MSBuildProjectName) enable From 744f11733d838ae1d3a82fdc33ae1fcd368c21c0 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:07:42 -0400 Subject: [PATCH 107/163] Revert "Bumped version to 2025.9.3" (#6369) This reverts commit 0b4b605524432431485852024dc5b35e16edd001. --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index cd1e95d0bd..71303d3529 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.3 + 2025.9.2 Bit.$(MSBuildProjectName) enable From ff092a031eb0489672095a76c8f4d4564222b835 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 24 Sep 2025 05:10:46 +0900 Subject: [PATCH 108/163] [PM-23229] Add extra validation to kdf changes + authentication data + unlock data (#6121) * Added MasterPasswordUnlock to UserDecryptionOptions as part of identity response * Implement support for authentication data and unlock data in kdf change * Extract to kdf command and add tests * Fix namespace * Delete empty file * Fix build * Clean up tests * Fix tests * Add comments * Cleanup * Cleanup * Cleanup * Clean-up and fix build * Address feedback; force new parameters on KDF change request * Clean-up and add tests * Re-add logger * Update logger to interface * Clean up, remove Kdf Request Model * Remove kdf request model tests * Fix types in test * Address feedback to rename request model and re-add tests * Fix namespace * Move comments * Rename InnerKdfRequestModel to KdfRequestModel --------- Co-authored-by: Maciej Zieniuk --- .../Auth/Controllers/AccountsController.cs | 18 +- .../Request/Accounts/KdfRequestModel.cs | 25 -- ...sswordUnlockDataAndAuthenticationModel.cs} | 6 +- .../Request/Accounts/PasswordRequestModel.cs | 14 +- .../Models/Requests/KdfRequestModel.cs | 26 ++ ...rPasswordAuthenticationDataRequestModel.cs | 21 ++ .../MasterPasswordUnlockDataRequestModel.cs | 22 ++ .../Models/Requests/UnlockDataRequestModel.cs | 2 +- src/Core/Entities/User.cs | 5 + .../KeyManagement/Kdf/IChangeKdfCommand.cs | 15 + .../Kdf/Implementations/ChangeKdfCommand.cs | 94 +++++ ...eyManagementServiceCollectionExtensions.cs | 3 + .../KeyManagement/Models/Data/KdfSettings.cs | 38 +++ .../Data/MasterPasswordAuthenticationData.cs | 18 + ...sterPasswordUnlockAndAuthenticationData.cs | 34 ++ .../Models/Data/MasterPasswordUnlockData.cs | 28 +- .../Models/Data/RotateUserAccountKeysData.cs | 2 +- src/Core/Services/IUserService.cs | 2 - .../Services/Implementations/UserService.cs | 33 -- src/Core/Utilities/KdfSettingsValidator.cs | 6 + .../Controllers/AccountsControllerTests.cs | 61 +++- .../Request/MasterPasswordUnlockDataModel.cs | 6 +- .../Request/Accounts/KdfRequestModelTests.cs | 65 ---- .../Utilities/KdfSettingsValidatorTests.cs | 36 ++ .../Kdf/ChangeKdfCommandTests.cs | 322 ++++++++++++++++++ 25 files changed, 729 insertions(+), 173 deletions(-) delete mode 100644 src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs rename src/Api/Auth/Models/Request/Accounts/{MasterPasswordUnlockDataModel.cs => MasterPasswordUnlockDataAndAuthenticationModel.cs} (90%) create mode 100644 src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs create mode 100644 src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs create mode 100644 src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs create mode 100644 src/Core/KeyManagement/Models/Data/KdfSettings.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs delete mode 100644 test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs create mode 100644 test/Api.Test/Utilities/KdfSettingsValidatorTests.cs create mode 100644 test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 0bed7c29c4..501399db38 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -16,6 +16,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; using Bit.Core.Services; @@ -39,7 +40,7 @@ public class AccountsController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; private readonly ITwoFactorEmailService _twoFactorEmailService; - + private readonly IChangeKdfCommand _changeKdfCommand; public AccountsController( IOrganizationService organizationService, @@ -51,7 +52,8 @@ public class AccountsController : Controller ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, - ITwoFactorEmailService twoFactorEmailService + ITwoFactorEmailService twoFactorEmailService, + IChangeKdfCommand changeKdfCommand ) { _organizationService = organizationService; @@ -64,7 +66,7 @@ public class AccountsController : Controller _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; _twoFactorEmailService = twoFactorEmailService; - + _changeKdfCommand = changeKdfCommand; } @@ -256,7 +258,7 @@ public class AccountsController : Controller } [HttpPost("kdf")] - public async Task PostKdf([FromBody] KdfRequestModel model) + public async Task PostKdf([FromBody] PasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -264,8 +266,12 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - var result = await _userService.ChangeKdfAsync(user, model.MasterPasswordHash, - model.NewMasterPasswordHash, model.Key, model.Kdf.Value, model.KdfIterations.Value, model.KdfMemory, model.KdfParallelism); + if (model.AuthenticationData == null || model.UnlockData == null) + { + throw new BadRequestException("AuthenticationData and UnlockData must be provided."); + } + + var result = await _changeKdfCommand.ChangeKdfAsync(user, model.MasterPasswordHash, model.AuthenticationData.ToData(), model.UnlockData.ToData()); if (result.Succeeded) { return; diff --git a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs deleted file mode 100644 index fc62f22bab..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Enums; -using Bit.Core.Utilities; - -namespace Bit.Api.Auth.Models.Request.Accounts; - -public class KdfRequestModel : PasswordRequestModel, IValidatableObject -{ - [Required] - public KdfType? Kdf { get; set; } - [Required] - public int? KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - - public override IEnumerable Validate(ValidationContext validationContext) - { - if (Kdf.HasValue && KdfIterations.HasValue) - { - return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism); - } - - return Enumerable.Empty(); - } -} diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs similarity index 90% rename from src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs rename to src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs index ba57788cec..da361e5a0c 100644 --- a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs @@ -7,7 +7,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; -public class MasterPasswordUnlockDataModel : IValidatableObject +public class MasterPasswordUnlockAndAuthenticationDataModel : IValidatableObject { public required KdfType KdfType { get; set; } public required int KdfIterations { get; set; } @@ -45,9 +45,9 @@ public class MasterPasswordUnlockDataModel : IValidatableObject } } - public MasterPasswordUnlockData ToUnlockData() + public MasterPasswordUnlockAndAuthenticationData ToUnlockData() { - var data = new MasterPasswordUnlockData + var data = new MasterPasswordUnlockAndAuthenticationData { KdfType = KdfType, KdfIterations = KdfIterations, diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 01da1f0f9f..8fa51e9f34 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,7 +1,7 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable +#nullable enable using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.Auth.Models.Request.Accounts; @@ -9,9 +9,13 @@ public class PasswordRequestModel : SecretVerificationRequestModel { [Required] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } + public required string NewMasterPasswordHash { get; set; } [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } [Required] - public string Key { get; set; } + public required string Key { get; set; } + + // Note: These will eventually become required, but not all consumers are moved over yet. + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } } diff --git a/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs new file mode 100644 index 0000000000..904304a633 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KdfRequestModel +{ + [Required] + public required KdfType KdfType { get; init; } + [Required] + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } + + public KdfSettings ToData() + { + return new KdfSettings + { + KdfType = KdfType, + Iterations = Iterations, + Memory = Memory, + Parallelism = Parallelism + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs new file mode 100644 index 0000000000..d65dc8fcb7 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class MasterPasswordAuthenticationDataRequestModel +{ + public required KdfRequestModel Kdf { get; init; } + public required string MasterPasswordAuthenticationHash { get; init; } + [StringLength(256)] public required string Salt { get; init; } + + public MasterPasswordAuthenticationData ToData() + { + return new MasterPasswordAuthenticationData + { + Kdf = Kdf.ToData(), + MasterPasswordAuthenticationHash = MasterPasswordAuthenticationHash, + Salt = Salt + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs new file mode 100644 index 0000000000..ce7a2b343f --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class MasterPasswordUnlockDataRequestModel +{ + public required KdfRequestModel Kdf { get; init; } + [EncryptedString] public required string MasterKeyWrappedUserKey { get; init; } + [StringLength(256)] public required string Salt { get; init; } + + public MasterPasswordUnlockData ToData() + { + return new MasterPasswordUnlockData + { + Kdf = Kdf.ToData(), + MasterKeyWrappedUserKey = MasterKeyWrappedUserKey, + Salt = Salt + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs index 23c3eb95d0..3af944110c 100644 --- a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs @@ -10,7 +10,7 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class UnlockDataRequestModel { // All methods to get to the userkey - public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; } + public required MasterPasswordUnlockAndAuthenticationDataModel MasterPasswordUnlockData { get; set; } public required IEnumerable EmergencyAccessUnlockData { get; set; } public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } public required IEnumerable PasskeyUnlockData { get; set; } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b92d22b0e3..12c527ed78 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -78,6 +78,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public DateTime? LastEmailChangeDate { get; set; } public bool VerifyDevices { get; set; } = true; + public string GetMasterPasswordSalt() + { + return Email.ToLowerInvariant().Trim(); + } + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs new file mode 100644 index 0000000000..081ae5248d --- /dev/null +++ b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.Kdf; + +/// +/// Command to change the Key Derivation Function (KDF) settings for a user. This includes +/// changing the masterpassword authentication hash, and the masterkey encrypted userkey. +/// The salt must not change during the KDF change. +/// +public interface IChangeKdfCommand +{ + public Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData); +} diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs new file mode 100644 index 0000000000..fe736f9ac6 --- /dev/null +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -0,0 +1,94 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.KeyManagement.Kdf.Implementations; + +/// +public class ChangeKdfCommand : IChangeKdfCommand +{ + private readonly IUserService _userService; + private readonly IPushNotificationService _pushService; + private readonly IUserRepository _userRepository; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly ILogger _logger; + + public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, IUserRepository userRepository, IdentityErrorDescriber describer, ILogger logger) + { + _userService = userService; + _pushService = pushService; + _userRepository = userRepository; + _identityErrorDescriber = describer; + _logger = logger; + } + + public async Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData) + { + ArgumentNullException.ThrowIfNull(user); + if (!await _userService.CheckPasswordAsync(user, masterPasswordAuthenticationHash)) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + // Validate to prevent user account from becoming un-decryptable from invalid parameters + // + // Prevent a de-synced salt value from creating an un-decryptable unlock method + authenticationData.ValidateSaltUnchangedForUser(user); + unlockData.ValidateSaltUnchangedForUser(user); + + // Currently KDF settings are not saved separately for authentication and unlock and must therefore be equal + if (!authenticationData.Kdf.Equals(unlockData.Kdf)) + { + throw new BadRequestException("KDF settings must be equal for authentication and unlock."); + } + var validationErrors = KdfSettingsValidator.Validate(unlockData.Kdf); + if (validationErrors.Any()) + { + throw new BadRequestException("KDF settings are invalid."); + } + + // Update the user with the new KDF settings + // This updates the authentication data and unlock data for the user separately. Currently these still + // use shared values for KDF settings and salt. + // The authentication hash, and the unlock data each are dependent on: + // - The master password (entered by the user every time) + // - The KDF settings (iterations, memory, parallelism) + // - The salt + // These combinations - (password, authentication hash, KDF settings, salt) and (password, unlock data, KDF settings, salt) + // must remain consistent to unlock correctly. + + // Authentication + // Note: This mutates the user but does not yet save it to DB. That is done atomically, later. + // This entire operation MUST be atomic to prevent a user from being locked out of their account. + // Salt is ensured to be the same as unlock data, and the value stored in the account and not updated. + // KDF is ensured to be the same as unlock data above and updated below. + var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash); + if (!result.Succeeded) + { + _logger.LogWarning("Change KDF failed for user {userId}.", user.Id); + return result; + } + + // Salt is ensured to be the same as authentication data, and the value stored in the account, and is not updated. + // Kdf - These will be seperated in the future, but for now are ensured to be the same as authentication data above. + user.Key = unlockData.MasterKeyWrappedUserKey; + user.Kdf = unlockData.Kdf.KdfType; + user.KdfIterations = unlockData.Kdf.Iterations; + user.KdfMemory = unlockData.Kdf.Memory; + user.KdfParallelism = unlockData.Kdf.Parallelism; + + var now = DateTime.UtcNow; + user.RevisionDate = user.AccountRevisionDate = now; + user.LastKdfChangeDate = now; + + await _userRepository.ReplaceAsync(user); + await _pushService.PushLogOutAsync(user.Id); + return IdentityResult.Success; + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index cacf3d4140..e4ebdb4860 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Kdf.Implementations; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.KeyManagement; @@ -15,5 +17,6 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Models/Data/KdfSettings.cs b/src/Core/KeyManagement/Models/Data/KdfSettings.cs new file mode 100644 index 0000000000..cc1e465330 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KdfSettings.cs @@ -0,0 +1,38 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class KdfSettings +{ + public required KdfType KdfType { get; init; } + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } + + public void ValidateUnchangedForUser(User user) + { + if (user.Kdf != KdfType || user.KdfIterations != Iterations || user.KdfMemory != Memory || user.KdfParallelism != Parallelism) + { + throw new ArgumentException("Invalid KDF settings."); + } + } + + public override bool Equals(object? obj) + { + if (obj is not KdfSettings other) + { + return false; + } + + return KdfType == other.KdfType && + Iterations == other.Iterations && + Memory == other.Memory && + Parallelism == other.Parallelism; + } + + public override int GetHashCode() + { + return HashCode.Combine(KdfType, Iterations, Memory, Parallelism); + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs new file mode 100644 index 0000000000..c0ae949a3f --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordAuthenticationData +{ + public required KdfSettings Kdf { get; init; } + public required string MasterPasswordAuthenticationHash { get; init; } + public required string Salt { get; init; } + + public void ValidateSaltUnchangedForUser(User user) + { + if (user.GetMasterPasswordSalt() != Salt) + { + throw new ArgumentException("Invalid master password salt."); + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs new file mode 100644 index 0000000000..e305d92fec --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -0,0 +1,34 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordUnlockAndAuthenticationData +{ + public KdfType KdfType { get; set; } + public int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + public required string Email { get; set; } + public required string MasterKeyAuthenticationHash { get; set; } + public required string MasterKeyEncryptedUserKey { get; set; } + public string? MasterPasswordHint { get; set; } + + public bool ValidateForUser(User user) + { + if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + { + return false; + } + else if (Email != user.Email) + { + return false; + } + else + { + return true; + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index 0ddfc03190..d1ab6f645b 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -1,34 +1,20 @@ #nullable enable + using Bit.Core.Entities; -using Bit.Core.Enums; namespace Bit.Core.KeyManagement.Models.Data; public class MasterPasswordUnlockData { - public KdfType KdfType { get; set; } - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } + public required KdfSettings Kdf { get; init; } + public required string MasterKeyWrappedUserKey { get; init; } + public required string Salt { get; init; } - public required string Email { get; set; } - public required string MasterKeyAuthenticationHash { get; set; } - public required string MasterKeyEncryptedUserKey { get; set; } - public string? MasterPasswordHint { get; set; } - - public bool ValidateForUser(User user) + public void ValidateSaltUnchangedForUser(User user) { - if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + if (user.GetMasterPasswordSalt() != Salt) { - return false; - } - else if (Email != user.Email) - { - return false; - } - else - { - return true; + throw new ArgumentException("Invalid master password salt."); } } } diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs index b89f19797f..557fb56ff3 100644 --- a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -19,7 +19,7 @@ public class RotateUserAccountKeysData public string AccountPublicKey { get; set; } // All methods to get to the userkey - public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public MasterPasswordUnlockAndAuthenticationData MasterPasswordUnlockData { get; set; } public IEnumerable EmergencyAccesses { get; set; } public IReadOnlyList OrganizationUsers { get; set; } public IEnumerable WebAuthnKeys { get; set; } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ef602be93a..412f9db36e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -38,8 +38,6 @@ public interface IUserService Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); - Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, - KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 386cb8c3d2..a36b9e37cc 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -777,39 +777,6 @@ public class UserService : UserManager, IUserService return IdentityResult.Success; } - public async Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, - string key, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (await CheckPasswordAsync(user, masterPassword)) - { - var result = await UpdatePasswordHash(user, newMasterPassword); - if (!result.Succeeded) - { - return result; - } - - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKdfChangeDate = now; - user.Key = key; - user.Kdf = kdf; - user.KdfIterations = kdfIterations; - user.KdfMemory = kdfMemory; - user.KdfParallelism = kdfParallelism; - await _userRepository.ReplaceAsync(user); - await _pushService.PushLogOutAsync(user.Id); - return IdentityResult.Success; - } - - Logger.LogWarning("Change KDF failed for user {userId}.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); - } - public async Task RefreshSecurityStampAsync(User user, string secret) { if (user == null) diff --git a/src/Core/Utilities/KdfSettingsValidator.cs b/src/Core/Utilities/KdfSettingsValidator.cs index db7936acff..f89e8ddb66 100644 --- a/src/Core/Utilities/KdfSettingsValidator.cs +++ b/src/Core/Utilities/KdfSettingsValidator.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.Utilities; @@ -34,4 +35,9 @@ public static class KdfSettingsValidator break; } } + + public static IEnumerable Validate(KdfSettings settings) + { + return Validate(settings.KdfType, settings.Iterations, settings.Memory, settings.Parallelism); + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index ce870c0860..e81d51281d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; @@ -33,6 +34,7 @@ public class AccountsControllerTests : IDisposable private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; private readonly ITwoFactorEmailService _twoFactorEmailService; + private readonly IChangeKdfCommand _changeKdfCommand; public AccountsControllerTests() @@ -47,7 +49,7 @@ public class AccountsControllerTests : IDisposable _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _twoFactorEmailService = Substitute.For(); - + _changeKdfCommand = Substitute.For(); _sut = new AccountsController( _organizationService, @@ -59,7 +61,8 @@ public class AccountsControllerTests : IDisposable _tdeOffboardingPasswordCommand, _twoFactorIsEnabledQuery, _featureService, - _twoFactorEmailService + _twoFactorEmailService, + _changeKdfCommand ); } @@ -242,12 +245,18 @@ public class AccountsControllerTests : IDisposable { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangePasswordAsync(user, default, default, default, default) + _userService.ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); - await _sut.PostPassword(new PasswordRequestModel()); + await _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }); - await _userService.Received(1).ChangePasswordAsync(user, default, default, default, default); + await _userService.Received(1).ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -256,7 +265,13 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnNullPrincipal(); await Assert.ThrowsAsync( - () => _sut.PostPassword(new PasswordRequestModel()) + () => _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }) ); } @@ -265,11 +280,17 @@ public class AccountsControllerTests : IDisposable { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangePasswordAsync(user, default, default, default, default) + _userService.ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Failed())); await Assert.ThrowsAsync( - () => _sut.PostPassword(new PasswordRequestModel()) + () => _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }) ); } @@ -593,6 +614,30 @@ public class AccountsControllerTests : IDisposable await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user); } + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullAuthenticationData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.AuthenticationData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullUnlockData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.UnlockData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into // something greater in order to share common test steps with diff --git a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs index 4c78c7015a..254f57a128 100644 --- a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs +++ b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs @@ -18,7 +18,7 @@ public class MasterPasswordUnlockDataModelTests [InlineData(KdfType.Argon2id, 3, 64, 4)] public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { - var model = new MasterPasswordUnlockDataModel + var model = new MasterPasswordUnlockAndAuthenticationDataModel { KdfType = kdfType, KdfIterations = kdfIterations, @@ -43,7 +43,7 @@ public class MasterPasswordUnlockDataModelTests [InlineData((KdfType)2, 2, 64, 4)] public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { - var model = new MasterPasswordUnlockDataModel + var model = new MasterPasswordUnlockAndAuthenticationDataModel { KdfType = kdfType, KdfIterations = kdfIterations, @@ -59,7 +59,7 @@ public class MasterPasswordUnlockDataModelTests Assert.NotNull(result.First().ErrorMessage); } - private static List Validate(MasterPasswordUnlockDataModel model) + private static List Validate(MasterPasswordUnlockAndAuthenticationDataModel model) { var results = new List(); Validator.TryValidateObject(model, new ValidationContext(model), results, true); diff --git a/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs b/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs deleted file mode 100644 index 612b7ad442..0000000000 --- a/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Core.Enums; -using Xunit; - -namespace Bit.Api.Test.Models.Request.Accounts; - -public class KdfRequestModelTests -{ - [Theory] - [InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle - [InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary - [InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary - [InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle - [InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary - [InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary - public void Validate_IsValid(KdfType kdfType, int? kdfIterations, int? kdfMemory, int? kdfParallelism) - { - var model = new KdfRequestModel - { - Kdf = kdfType, - KdfIterations = kdfIterations, - KdfMemory = kdfMemory, - KdfParallelism = kdfParallelism, - Key = "TEST", - NewMasterPasswordHash = "TEST", - }; - - var results = Validate(model); - Assert.Empty(results); - } - - [Theory] - [InlineData(null, 350_000, null, null, 1)] // Although KdfType is nullable, it's marked as [Required] - [InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations - [InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations - [InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0 - [InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory - [InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value - [InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory - [InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value - public void Validate_Fails(KdfType? kdfType, int? kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures) - { - var model = new KdfRequestModel - { - Kdf = kdfType, - KdfIterations = kdfIterations, - KdfMemory = kdfMemory, - KdfParallelism = kdfParallelism, - Key = "TEST", - NewMasterPasswordHash = "TEST", - }; - - var results = Validate(model); - Assert.NotEmpty(results); - Assert.Equal(expectedFailures, results.Count); - } - - public static List Validate(KdfRequestModel model) - { - var results = new List(); - Validator.TryValidateObject(model, new ValidationContext(model), results); - return results; - } -} diff --git a/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs b/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs new file mode 100644 index 0000000000..5c556979c7 --- /dev/null +++ b/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs @@ -0,0 +1,36 @@ +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Api.Test.Utilities; + +public class KdfSettingsValidatorTests +{ + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle + [InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary + [InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary + [InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle + [InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary + [InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary + public void Validate_IsValid(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism); + Assert.Empty(results); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations + [InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations + [InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0 + [InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory + [InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value + [InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory + [InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value + public void Validate_Fails(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures) + { + var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism); + Assert.NotEmpty(results); + Assert.Equal(expectedFailures, results.Count()); + } +} diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs new file mode 100644 index 0000000000..02e04b9ce9 --- /dev/null +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -0,0 +1,322 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf.Implementations; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Kdf; + +[SutProviderCustomize] +public class ChangeKdfCommandTests +{ + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(IdentityResult.Success)); + + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = user.GetMasterPasswordSalt() + }; + + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id + && u.Kdf == Enums.KdfType.Argon2id + && u.KdfIterations == 4 + && u.KdfMemory == 512 + && u.KdfParallelism == 4 + )); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider) + { + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = "salt" + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = "salt" + }; + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(null!, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(false)); + + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = user.GetMasterPasswordSalt() + }; + + var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "PasswordMismatch"); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider sutProvider, User user) + { + var constantKdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 5, + Memory = 1024, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = constantKdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = constantKdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(IdentityResult.Success)); + + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id + && u.Kdf == constantKdf.KdfType + && u.KdfIterations == constantKdf.Iterations + && u.KdfMemory == constantKdf.Memory + && u.KdfParallelism == constantKdf.Parallelism + && u.Key == "new-wrapped-key" + )); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = new KdfSettings { KdfType = Enums.KdfType.PBKDF2_SHA256, Iterations = 100000 }, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider sutProvider, User user, KdfSettings kdf) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = "different-salt" + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider sutProvider, User user, KdfSettings kdf) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = "different-salt" + }; + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(failedResult)); + + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = user.GetMasterPasswordSalt() + }; + + var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + Assert.False(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + // Create invalid KDF settings (iterations too low for PBKDF2) + var invalidKdf = new KdfSettings + { + KdfType = Enums.KdfType.PBKDF2_SHA256, + Iterations = 1000, // This is below the minimum of 600,000 + Memory = null, + Parallelism = null + }; + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = invalidKdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = invalidKdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + + Assert.Equal("KDF settings are invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + // Create invalid Argon2 KDF settings (memory too high) + var invalidKdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 3, // Valid + Memory = 2048, // This is above the maximum of 1024 + Parallelism = 4 // Valid + }; + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = invalidKdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = invalidKdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + + Assert.Equal("KDF settings are invalid.", exception.Message); + } + +} From 6e4f05ebd388e9a1dc9ec8103d070b516b65c1ae Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:42:56 -0400 Subject: [PATCH 109/163] fix: change policies to static strings and update auth owned endpoints (#6296) --- src/Api/Auth/Controllers/AccountsController.cs | 3 ++- .../Auth/Controllers/AuthRequestsController.cs | 3 ++- .../Controllers/EmergencyAccessController.cs | 2 +- src/Api/Auth/Controllers/TwoFactorController.cs | 3 ++- src/Api/Auth/Controllers/WebAuthnController.cs | 3 ++- src/Api/Startup.cs | 17 +++++++++-------- src/Core/Auth/Identity/Policies.cs | 8 +++++++- 7 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 501399db38..19165a5a1c 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -9,6 +9,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -27,7 +28,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("accounts")] -[Authorize("Application")] +[Authorize(Policies.Application)] public class AccountsController : Controller { private readonly IOrganizationService _organizationService; diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index e4a9027f20..4da3a2f491 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -5,6 +5,7 @@ using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; using Bit.Core.Exceptions; @@ -18,7 +19,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("auth-requests")] -[Authorize("Application")] +[Authorize(Policies.Application)] public class AuthRequestsController( IUserService userService, IAuthRequestRepository authRequestRepository, diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index b849dc3e07..016cd82fe2 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -18,7 +18,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("emergency-access")] -[Authorize("Application")] +[Authorize(Core.Auth.Identity.Policies.Application)] public class EmergencyAccessController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 886ed2cd20..0af46fb57c 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -7,6 +7,7 @@ using Bit.Api.Auth.Models.Response.TwoFactor; using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; @@ -26,7 +27,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("two-factor")] -[Authorize("Web")] +[Authorize(Policies.Web)] public class TwoFactorController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index bb17607954..60b8621c5e 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -20,7 +21,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("webauthn")] -[Authorize("Web")] +[Authorize(Policies.Web)] public class WebAuthnController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1d5a1609f4..cc50a1b362 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -34,6 +34,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Identity; +using Bit.Core.Enums; #if !OSS @@ -105,40 +106,40 @@ public class Startup services.AddCustomIdentityServices(globalSettings); services.AddIdentityAuthenticationServices(globalSettings, Environment, config => { - config.AddPolicy("Application", policy => + config.AddPolicy(Policies.Application, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api); }); - config.AddPolicy("Web", policy => + config.AddPolicy(Policies.Web, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api); - policy.RequireClaim(JwtClaimTypes.ClientId, "web"); + policy.RequireClaim(JwtClaimTypes.ClientId, BitwardenClient.Web); }); - config.AddPolicy("Push", policy => + config.AddPolicy(Policies.Push, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiPush); }); - config.AddPolicy("Licensing", policy => + config.AddPolicy(Policies.Licensing, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiLicensing); }); - config.AddPolicy("Organization", policy => + config.AddPolicy(Policies.Organization, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiOrganization); }); - config.AddPolicy("Installation", policy => + config.AddPolicy(Policies.Installation, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiInstallation); }); - config.AddPolicy("Secrets", policy => + config.AddPolicy(Policies.Secrets, policy => { policy.RequireAuthenticatedUser(); policy.RequireAssertion(ctx => ctx.User.HasClaim(c => diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs index 78d86d06a4..b2d94b0a6e 100644 --- a/src/Core/Auth/Identity/Policies.cs +++ b/src/Core/Auth/Identity/Policies.cs @@ -6,5 +6,11 @@ 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 + public const string Application = "Application"; // [Authorize(Policy = Policies.Application)] + public const string Web = "Web"; // [Authorize(Policy = Policies.Web)] + public const string Push = "Push"; // [Authorize(Policy = Policies.Push)] + public const string Licensing = "Licensing"; // [Authorize(Policy = Policies.Licensing)] + public const string Organization = "Organization"; // [Authorize(Policy = Policies.Organization)] + public const string Installation = "Installation"; // [Authorize(Policy = Policies.Installation)] + public const string Secrets = "Secrets"; // [Authorize(Policy = Policies.Secrets)] } From 6edab46d979f3bc28c733a8f9cb2bfbee0251cee Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:52:04 -0500 Subject: [PATCH 110/163] [PM-24357] Do not purge ciphers in the default collection (#6320) * do not purge ciphers in the default collection * Update `DeleteByOrganizationId` procedure to be more performant based on PR review feedback * update EF integration for purge to match new SQL implementation * update Cipher_DeleteByOrganizationId based on PR feedback from dbops team --- .../Vault/Repositories/CipherRepository.cs | 23 +-- .../Cipher/Cipher_DeleteByOrganizationId.sql | 120 ++++++++----- .../Repositories/CipherRepositoryTests.cs | 160 ++++++++++++++++++ ...9-22_00_ExcludeDefaultCiphersFromPurge.sql | 91 ++++++++++ 4 files changed, 345 insertions(+), 49 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 4b2d09f87b..d88f0e98bb 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -281,17 +281,20 @@ public class CipherRepository : Repository + cc.Collection.Type == CollectionType.DefaultUserCollection) + select c; + dbContext.RemoveRange(ciphersToDelete); - var ciphers = from c in dbContext.Ciphers - where c.OrganizationId == organizationId - select c; - dbContext.RemoveRange(ciphers); + var collectionCiphersToRemove = from cc in dbContext.CollectionCiphers + join col in dbContext.Collections on cc.CollectionId equals col.Id + join c in dbContext.Ciphers on cc.CipherId equals c.Id + where col.Type != CollectionType.DefaultUserCollection + && c.OrganizationId == organizationId + select cc; + dbContext.RemoveRange(collectionCiphersToRemove); await OrganizationUpdateStorage(organizationId); await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql index d2324a1d00..c14a612b0f 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql @@ -1,49 +1,91 @@ CREATE PROCEDURE [dbo].[Cipher_DeleteByOrganizationId] - @OrganizationId AS UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; - DECLARE @BatchSize INT = 100 + DECLARE @BatchSize INT = 1000; - -- Delete collection ciphers - WHILE @BatchSize > 0 - BEGIN - BEGIN TRANSACTION Cipher_DeleteByOrganizationId_CC + BEGIN TRY + BEGIN TRANSACTION; - DELETE TOP(@BatchSize) CC - FROM - [dbo].[CollectionCipher] CC - INNER JOIN - [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] - WHERE - C.[OrganizationId] = @OrganizationId + --------------------------------------------------------------------- + -- 1. Delete organization ciphers that are NOT in any default + -- user collection (Collection.Type = 1). + --------------------------------------------------------------------- + WHILE 1 = 1 + BEGIN + ;WITH Target AS + ( + SELECT TOP (@BatchSize) C.Id + FROM dbo.Cipher C + WHERE C.OrganizationId = @OrganizationId + AND NOT EXISTS ( + SELECT 1 + FROM dbo.CollectionCipher CC2 + INNER JOIN dbo.Collection Col2 + ON Col2.Id = CC2.CollectionId + AND Col2.Type = 1 -- Default user collection + WHERE CC2.CipherId = C.Id + ) + ORDER BY C.Id -- Deterministic ordering (matches clustered index) + ) + DELETE C + FROM dbo.Cipher C + INNER JOIN Target T ON T.Id = C.Id; - SET @BatchSize = @@ROWCOUNT + IF @@ROWCOUNT = 0 BREAK; + END - COMMIT TRANSACTION Cipher_DeleteByOrganizationId_CC - END + --------------------------------------------------------------------- + -- 2. Remove remaining CollectionCipher rows that reference + -- non-default (Type = 0 / shared) collections, for ciphers + -- that were preserved because they belong to at least one + -- default (Type = 1) collection. + --------------------------------------------------------------------- + SET @BatchSize = 1000; + WHILE 1 = 1 + BEGIN + ;WITH ToDelete AS + ( + SELECT TOP (@BatchSize) + CC.CipherId, + CC.CollectionId + FROM dbo.CollectionCipher CC + INNER JOIN dbo.Collection Col + ON Col.Id = CC.CollectionId + AND Col.Type = 0 -- Non-default collections + INNER JOIN dbo.Cipher C + ON C.Id = CC.CipherId + WHERE C.OrganizationId = @OrganizationId + ORDER BY CC.CollectionId, CC.CipherId -- Matches clustered index + ) + DELETE CC + FROM dbo.CollectionCipher CC + INNER JOIN ToDelete TD + ON CC.CipherId = TD.CipherId + AND CC.CollectionId = TD.CollectionId; - -- Reset batch size - SET @BatchSize = 100 + IF @@ROWCOUNT = 0 BREAK; + END - -- Delete ciphers - WHILE @BatchSize > 0 - BEGIN - BEGIN TRANSACTION Cipher_DeleteByOrganizationId + --------------------------------------------------------------------- + -- 3. Bump revision date (inside transaction for consistency) + --------------------------------------------------------------------- + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; - DELETE TOP(@BatchSize) - FROM - [dbo].[Cipher] - WHERE - [OrganizationId] = @OrganizationId + COMMIT TRANSACTION ; - SET @BatchSize = @@ROWCOUNT - - COMMIT TRANSACTION Cipher_DeleteByOrganizationId - END - - -- Cleanup organization - EXEC [dbo].[Organization_UpdateStorage] @OrganizationId - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId -END \ No newline at end of file + --------------------------------------------------------------------- + -- 4. Update storage usage (outside the transaction to avoid + -- holding locks during long-running calculation) + --------------------------------------------------------------------- + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END CATCH + END + GO diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index ef28d776d7..ee8cd0247d 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1241,4 +1241,164 @@ public class CipherRepositoryTests Assert.NotNull(archivedCipher); Assert.NotNull(archivedCipher.ArchivedDate); } + + [DatabaseTheory, DatabaseData] + public async Task DeleteByOrganizationIdAsync_ExcludesDefaultCollectionCiphers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var defaultCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Default Collection", + OrganizationId = organization.Id, + Type = CollectionType.DefaultUserCollection + }); + + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection", + OrganizationId = organization.Id, + }); + + async Task CreateOrgCipherAsync() => await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var cipherInDefaultCollection = await CreateOrgCipherAsync(); + var cipherInSharedCollection = await CreateOrgCipherAsync(); + var cipherInBothCollections = await CreateOrgCipherAsync(); + var unassignedCipher = await CreateOrgCipherAsync(); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInDefaultCollection.Id, organization.Id, + new List { defaultCollection.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection.Id, organization.Id, + new List { sharedCollection.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInBothCollections.Id, organization.Id, + new List { defaultCollection.Id, sharedCollection.Id }); + + await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); + + var remainingCipherInDefault = await cipherRepository.GetByIdAsync(cipherInDefaultCollection.Id); + var deletedCipherInShared = await cipherRepository.GetByIdAsync(cipherInSharedCollection.Id); + var remainingCipherInBoth = await cipherRepository.GetByIdAsync(cipherInBothCollections.Id); + var deletedUnassignedCipher = await cipherRepository.GetByIdAsync(unassignedCipher.Id); + + Assert.Null(deletedCipherInShared); + Assert.Null(deletedUnassignedCipher); + + Assert.NotNull(remainingCipherInDefault); + Assert.NotNull(remainingCipherInBoth); + + var remainingCollectionCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(organization.Id); + + // Should still have the default collection cipher relationships + Assert.Contains(remainingCollectionCiphers, cc => + cc.CipherId == cipherInDefaultCollection.Id && cc.CollectionId == defaultCollection.Id); + Assert.Contains(remainingCollectionCiphers, cc => + cc.CipherId == cipherInBothCollections.Id && cc.CollectionId == defaultCollection.Id); + + // Should not have the shared collection cipher relationships + Assert.DoesNotContain(remainingCollectionCiphers, cc => cc.CollectionId == sharedCollection.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteByOrganizationIdAsync_DeletesAllWhenNoDefaultCollections( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var sharedCollection1 = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection 1", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + var sharedCollection2 = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection 2", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + // Create ciphers + var cipherInSharedCollection1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var cipherInSharedCollection2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var unassignedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection1.Id, organization.Id, + new List { sharedCollection1.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection2.Id, organization.Id, + new List { sharedCollection2.Id }); + + await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); + + var deletedCipher1 = await cipherRepository.GetByIdAsync(cipherInSharedCollection1.Id); + var deletedCipher2 = await cipherRepository.GetByIdAsync(cipherInSharedCollection2.Id); + var deletedUnassignedCipher = await cipherRepository.GetByIdAsync(unassignedCipher.Id); + + Assert.Null(deletedCipher1); + Assert.Null(deletedCipher2); + Assert.Null(deletedUnassignedCipher); + + // All collection cipher relationships should be removed + var remainingCollectionCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(organization.Id); + Assert.Empty(remainingCollectionCiphers); + } } diff --git a/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql b/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql new file mode 100644 index 0000000000..8eb37a570f --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql @@ -0,0 +1,91 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_DeleteByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + DECLARE @BatchSize INT = 1000; + + BEGIN TRY + BEGIN TRANSACTION; + + --------------------------------------------------------------------- + -- 1. Delete organization ciphers that are NOT in any default + -- user collection (Collection.Type = 1). + --------------------------------------------------------------------- + WHILE 1 = 1 + BEGIN + ;WITH Target AS + ( + SELECT TOP (@BatchSize) C.Id + FROM dbo.Cipher C + WHERE C.OrganizationId = @OrganizationId + AND NOT EXISTS ( + SELECT 1 + FROM dbo.CollectionCipher CC2 + INNER JOIN dbo.Collection Col2 + ON Col2.Id = CC2.CollectionId + AND Col2.Type = 1 -- Default user collection + WHERE CC2.CipherId = C.Id + ) + ORDER BY C.Id -- Deterministic ordering (matches clustered index) + ) + DELETE C + FROM dbo.Cipher C + INNER JOIN Target T ON T.Id = C.Id; + + IF @@ROWCOUNT = 0 BREAK; + END + + --------------------------------------------------------------------- + -- 2. Remove remaining CollectionCipher rows that reference + -- non-default (Type = 0 / shared) collections, for ciphers + -- that were preserved because they belong to at least one + -- default (Type = 1) collection. + --------------------------------------------------------------------- + SET @BatchSize = 1000; + WHILE 1 = 1 + BEGIN + ;WITH ToDelete AS + ( + SELECT TOP (@BatchSize) + CC.CipherId, + CC.CollectionId + FROM dbo.CollectionCipher CC + INNER JOIN dbo.Collection Col + ON Col.Id = CC.CollectionId + AND Col.Type = 0 -- Non-default collections + INNER JOIN dbo.Cipher C + ON C.Id = CC.CipherId + WHERE C.OrganizationId = @OrganizationId + ORDER BY CC.CollectionId, CC.CipherId -- Matches clustered index + ) + DELETE CC + FROM dbo.CollectionCipher CC + INNER JOIN ToDelete TD + ON CC.CipherId = TD.CipherId + AND CC.CollectionId = TD.CollectionId; + + IF @@ROWCOUNT = 0 BREAK; + END + + --------------------------------------------------------------------- + -- 3. Bump revision date (inside transaction for consistency) + --------------------------------------------------------------------- + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; + + COMMIT TRANSACTION ; + + --------------------------------------------------------------------- + -- 4. Update storage usage (outside the transaction to avoid + -- holding locks during long-running calculation) + --------------------------------------------------------------------- + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END CATCH + END + GO From 68f7e8c15c4284ee26ef5121ee935d2f525e3b3c Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:30:43 -0400 Subject: [PATCH 111/163] chore(feature-flag) Added feature flag for pm-22110-disable-alternate-login-methods --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 96ee509db1..5c00db9da4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,7 @@ public static class FeatureFlagKeys public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; + public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; From 4b10c1641989c6e65b299d3343d46b499cdf44fd Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 24 Sep 2025 18:23:15 -0400 Subject: [PATCH 112/163] fix(global-settings): [PM-26092] Token Refresh Doc Enhancement (#6367) * fix(global-settings): [PM-26092] Token Refresh Doc Enhancement - Enhanced documentation and wording for token refresh. --- src/Core/Settings/GlobalSettings.cs | 25 ++++++++++++++++++++---- src/Identity/IdentityServer/ApiClient.cs | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 107fd29236..546e668093 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -473,17 +473,34 @@ public class GlobalSettings : IGlobalSettings 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. + /// Sliding lifetime of a refresh token in seconds. + /// + /// Each time the refresh token is used before the sliding window ends, its lifetime is extended by another SlidingRefreshTokenLifetimeSeconds. + /// + /// If AbsoluteRefreshTokenLifetimeSeconds > 0, the sliding extensions are bounded by the absolute maximum lifetime. + /// If SlidingRefreshTokenLifetimeSeconds = 0, sliding mode is invalid (refresh tokens cannot be used). /// public int? SlidingRefreshTokenLifetimeSeconds { get; set; } /// - /// Global override for absolute refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// Maximum lifetime of a refresh token in seconds. + /// + /// Token cannot be refreshed by any means beyond the absolute refresh expiration. + /// + /// When setting this value to 0, the following effect applies: + /// If ApplyAbsoluteExpirationOnRefreshToken is set to true, the behavior is the same as when no refresh tokens are used. + /// If ApplyAbsoluteExpirationOnRefreshToken is set to false, refresh tokens only expire after the SlidingRefreshTokenLifetimeSeconds has passed. /// public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; } /// - /// Global override for refresh token expiration policy. False = Sliding (default), True = Absolute. + /// Controls whether refresh tokens expire absolutely or on a sliding window basis. + /// + /// Absolute: + /// Token expires at a fixed point in time (defined by AbsoluteRefreshTokenLifetimeSeconds). Usage does not extend lifetime. + /// + /// Sliding(default): + /// Token lifetime is renewed on each use, by the amount in SlidingRefreshTokenLifetimeSeconds. Extensions stop once AbsoluteRefreshTokenLifetimeSeconds is reached (if set > 0). /// - public bool UseAbsoluteRefreshTokenExpiration { get; set; } = false; + public bool ApplyAbsoluteExpirationOnRefreshToken { get; set; } = false; } public class DataProtectionSettings diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 61b51797c0..ead19813ec 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -20,7 +20,7 @@ public class ApiClient : Client AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType }; // Use global setting: false = Sliding (default), true = Absolute - RefreshTokenExpiration = globalSettings.IdentityServer.UseAbsoluteRefreshTokenExpiration + RefreshTokenExpiration = globalSettings.IdentityServer.ApplyAbsoluteExpirationOnRefreshToken ? TokenExpiration.Absolute : TokenExpiration.Sliding; From f0953ed6b0007af0168993a57f2c5f601c90d16c Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 24 Sep 2025 15:25:40 -0700 Subject: [PATCH 113/163] [PM-26126] Add includeMemberItems query param to GET /organization-details (#6376) --- src/Api/Vault/Controllers/CiphersController.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 6249e264c0..c0a974bce2 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -336,13 +336,15 @@ public class CiphersController : Controller } [HttpGet("organization-details")] - public async Task> GetOrganizationCiphers(Guid organizationId) + public async Task> GetOrganizationCiphers(Guid organizationId, bool includeMemberItems = false) { if (!await CanAccessAllCiphersAsync(organizationId)) { throw new NotFoundException(); } - var allOrganizationCiphers = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + + bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems; + var allOrganizationCiphers = excludeDefaultUserCollections ? await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) : From b83f95f78ccb475006e2118909a6d8cf30fb605c Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:14:02 +1000 Subject: [PATCH 114/163] [PM-25097] Remove DeleteClaimedUserAccountRefactor flag (#6364) * Remove feature flag * Remove old code --- .../OrganizationUsersController.cs | 81 +-- .../CommandResult.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 12 +- ...ClaimedOrganizationUserAccountValidator.cs | 8 +- .../DeleteUserValidationRequest.cs | 2 +- .../Errors.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 4 +- ...laimedOrganizationUserAccountValidator.cs} | 4 +- .../ValidationResult.cs | 2 +- ...teClaimedOrganizationUserAccountCommand.cs | 239 -------- ...teClaimedOrganizationUserAccountCommand.cs | 19 - src/Core/Constants.cs | 1 - ...OrganizationServiceCollectionExtensions.cs | 8 +- .../OrganizationUserControllerTests.cs | 6 +- .../OrganizationUsersControllerTests.cs | 26 +- ...medOrganizationUserAccountCommandTests.cs} | 40 +- ...dOrganizationUserAccountValidatorTests.cs} | 34 +- ...imedOrganizationUserAccountCommandTests.cs | 526 ------------------ 18 files changed, 86 insertions(+), 930 deletions(-) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/CommandResult.cs (98%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs => DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs} (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/DeleteClaimedOrganizationUserAccountValidator.cs (93%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/DeleteUserValidationRequest.cs (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/Errors.cs (97%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs => DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs} (87%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs => DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs} (65%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/ValidationResult.cs (97%) delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/{DeleteClaimedOrganizationUserAccountCommandvNextTests.cs => DeleteClaimedOrganizationUserAccountCommandTests.cs} (93%) rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/{DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs => DeleteClaimedOrganizationUserAccountValidatorTests.cs} (97%) delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 16d6984334..74ac9b1255 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -11,7 +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.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -61,7 +61,6 @@ 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; @@ -90,7 +89,6 @@ public class OrganizationUsersController : Controller IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, - IDeleteClaimedOrganizationUserAccountCommandvNext deleteClaimedOrganizationUserAccountCommandvNext, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, @@ -119,7 +117,6 @@ public class OrganizationUsersController : Controller _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; - _deleteClaimedOrganizationUserAccountCommandvNext = deleteClaimedOrganizationUserAccountCommandvNext; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; @@ -539,21 +536,22 @@ public class OrganizationUsersController : Controller [HttpDelete("{id}/delete-account")] [Authorize] - public async Task DeleteAccount(Guid orgId, Guid id) + public async Task DeleteAccount(Guid orgId, Guid id) { - if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) { - await DeleteAccountvNext(orgId, id); - return; + return TypedResults.Unauthorized(); } - var currentUser = await _userService.GetUserByPrincipalAsync(User); - if (currentUser == null) - { - throw new UnauthorizedAccessException(); - } + var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value); - await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); + return commandResult.Result.Match( + error => error is NotFoundError + ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) + : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), + TypedResults.Ok + ); } [HttpPost("{id}/delete-account")] @@ -564,43 +562,24 @@ 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) + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) { throw new UnauthorizedAccessException(); } - var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); + var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value); - return new ListResponseModel(results.Select(r => - new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); + 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); } [HttpPost("delete-account")] @@ -611,24 +590,6 @@ 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/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs similarity index 98% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs index 3dfbe4dbda..fbb00a908a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// Represents the result of a command. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs index 3064a426fa..87c24c3ab4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs @@ -8,18 +8,18 @@ using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public class DeleteClaimedOrganizationUserAccountCommandvNext( +public class DeleteClaimedOrganizationUserAccountCommand( IUserService userService, IEventService eventService, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, IPushNotificationService pushService, - ILogger logger, - IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext) - : IDeleteClaimedOrganizationUserAccountCommandvNext + ILogger logger, + IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator) + : IDeleteClaimedOrganizationUserAccountCommand { public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) { @@ -35,7 +35,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNext( var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses); - var validationResults = (await deleteClaimedOrganizationUserAccountValidatorvNext.ValidateAsync(internalRequests)).ToList(); + var validationResults = (await deleteClaimedOrganizationUserAccountValidator.ValidateAsync(internalRequests)).ToList(); var validRequests = validationResults.ValidRequests(); await CancelPremiumsAsync(validRequests); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs similarity index 93% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs index 7a88841d2f..315d45ea69 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs @@ -2,14 +2,14 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Repositories; -using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext.ValidationResultHelpers; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public class DeleteClaimedOrganizationUserAccountValidatorvNext( +public class DeleteClaimedOrganizationUserAccountValidator( ICurrentContext currentContext, IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidatorvNext + IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidator { public async Task>> ValidateAsync(IEnumerable requests) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs index 5fd95dc73c..067d7ce04c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs @@ -1,6 +1,6 @@ using Bit.Core.Entities; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public class DeleteUserValidationRequest { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs similarity index 97% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs index d991a882b8..6c8f7ee00c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// A strongly typed error containing a reason that an action failed. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs similarity index 87% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs index 2c462a2acf..983a3a4f21 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -1,6 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public interface IDeleteClaimedOrganizationUserAccountCommandvNext +public interface IDeleteClaimedOrganizationUserAccountCommand { /// /// Removes a user from an organization and deletes all of their associated user data. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs similarity index 65% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs index f6125a0355..f1a2c71b1b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs @@ -1,6 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public interface IDeleteClaimedOrganizationUserAccountValidatorvNext +public interface IDeleteClaimedOrganizationUserAccountValidator { Task>> ValidateAsync(IEnumerable requests); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs similarity index 97% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs index 23d2fbb7ce..c84a0aeda1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// Represents the result of validating a request. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 60a1c8bfbf..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -1,239 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; -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; - -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; - -public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand -{ - private readonly IUserService _userService; - private readonly IEventService _eventService; - private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IUserRepository _userRepository; - private readonly ICurrentContext _currentContext; - private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IPushNotificationService _pushService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IProviderUserRepository _providerUserRepository; - public DeleteClaimedOrganizationUserAccountCommand( - IUserService userService, - IEventService eventService, - IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, - IOrganizationUserRepository organizationUserRepository, - IUserRepository userRepository, - ICurrentContext currentContext, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPushNotificationService pushService, - IOrganizationRepository organizationRepository, - IProviderUserRepository providerUserRepository) - { - _userService = userService; - _eventService = eventService; - _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; - _organizationUserRepository = organizationUserRepository; - _userRepository = userRepository; - _currentContext = currentContext; - _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _pushService = pushService; - _organizationRepository = organizationRepository; - _providerUserRepository = providerUserRepository; - } - - public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) - { - var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (organizationUser == null || organizationUser.OrganizationId != organizationId) - { - throw new NotFoundException("Member not found."); - } - - var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId }); - var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); - - await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); - - var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); - if (user == null) - { - throw new NotFoundException("Member not found."); - } - - await _userService.DeleteAsync(user); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); - } - - public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(orgUserIds); - var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); - var users = await _userRepository.GetManyAsync(userIds); - - var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); - var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); - - var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); - foreach (var orgUserId in orgUserIds) - { - try - { - var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); - if (orgUser == null || orgUser.OrganizationId != organizationId) - { - throw new NotFoundException("Member not found."); - } - - await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); - - var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); - if (user == null) - { - throw new NotFoundException("Member not found."); - } - - await ValidateUserMembershipAndPremiumAsync(user); - - results.Add((orgUserId, string.Empty)); - } - catch (Exception ex) - { - results.Add((orgUserId, ex.Message)); - } - } - - var orgUserResultsToDelete = results.Where(result => string.IsNullOrEmpty(result.ErrorMessage)); - var orgUsersToDelete = orgUsers.Where(orgUser => orgUserResultsToDelete.Any(result => orgUser.Id == result.OrganizationUserId)); - var usersToDelete = users.Where(user => orgUsersToDelete.Any(orgUser => orgUser.UserId == user.Id)); - - if (usersToDelete.Any()) - { - await DeleteManyAsync(usersToDelete); - } - - await LogDeletedOrganizationUsersAsync(orgUsers, results); - - return results; - } - - private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary claimedStatus, bool hasOtherConfirmedOwners) - { - if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) - { - throw new BadRequestException("You cannot delete a member with Invited status."); - } - - if (deletingUserId.HasValue && orgUser.UserId.Value == deletingUserId.Value) - { - throw new BadRequestException("You cannot delete yourself."); - } - - if (orgUser.Type == OrganizationUserType.Owner) - { - if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(organizationId)) - { - throw new BadRequestException("Only owners can delete other owners."); - } - - if (!hasOtherConfirmedOwners) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - } - - if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId)) - { - throw new BadRequestException("Custom users can not delete admins."); - } - - if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed) - { - throw new BadRequestException("Member is not claimed by the organization."); - } - } - - private async Task LogDeletedOrganizationUsersAsync( - IEnumerable orgUsers, - IEnumerable<(Guid OrgUserId, string? ErrorMessage)> results) - { - var eventDate = DateTime.UtcNow; - var events = new List<(OrganizationUser OrgUser, EventType Event, DateTime? EventDate)>(); - - foreach (var (orgUserId, errorMessage) in results) - { - var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); - // If the user was not found or there was an error, we skip logging the event - if (orgUser == null || !string.IsNullOrEmpty(errorMessage)) - { - continue; - } - - events.Add((orgUser, EventType.OrganizationUser_Deleted, eventDate)); - } - - if (events.Any()) - { - await _eventService.LogOrganizationUserEventsAsync(events); - } - } - private async Task DeleteManyAsync(IEnumerable users) - { - - await _userRepository.DeleteManyAsync(users); - foreach (var user in users) - { - await _pushService.PushLogOutAsync(user.Id); - } - - } - - private async Task ValidateUserMembershipAndPremiumAsync(User user) - { - // Check if user is the only owner of any organizations. - var onlyOwnerCount = await _organizationUserRepository.GetCountByOnlyOwnerAsync(user.Id); - if (onlyOwnerCount > 0) - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); - } - - var orgs = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed); - if (orgs.Count == 1) - { - var org = await _organizationRepository.GetByIdAsync(orgs.First().OrganizationId); - if (org != null && (!org.Enabled || string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))) - { - var orgCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(org.Id); - if (orgCount <= 1) - { - await _organizationRepository.DeleteAsync(org); - } - else - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); - } - } - } - - var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id); - if (onlyOwnerProviderCount > 0) - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); - } - - if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId)) - { - try - { - await _userService.CancelPremiumAsync(user); - } - catch (GatewayException) { } - } - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 1c79687be9..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; - -public interface IDeleteClaimedOrganizationUserAccountCommand -{ - /// - /// 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/Constants.cs b/src/Core/Constants.cs index 5c00db9da4..97c1399719 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,7 +134,6 @@ public static class FeatureFlagKeys 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"; /* 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 1c38a27d1e..da05bc929c 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,7 +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.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -132,12 +132,10 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); - // vNext implementations (feature flagged) - services.AddScoped(); - services.AddScoped(); + 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 b7839467e8..7c61a88bd8 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -7,7 +7,7 @@ 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.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -33,10 +33,6 @@ public class OrganizationUserControllerTests : IClassFixture sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - - await sutProvider.Sut.DeleteAccount(orgId, id); - - await sutProvider.GetDependency() - .Received(1) - .DeleteUserAsync(orgId, id, currentUser.Id); - } - - [Theory] - [BitAutoData] - public async Task DeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( + public async Task DeleteAccount_WhenCurrentUserNotFound_ReturnsUnauthorizedResult( Guid orgId, Guid id, SutProvider sutProvider) { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)null); - await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteAccount(orgId, id)); + var result = await sutProvider.Sut.DeleteAccount(orgId, id); + + Assert.IsType(result); } [Theory] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs similarity index 93% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs index 679c1914c6..c223520a04 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; @@ -17,12 +17,12 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; [SutProviderCustomize] -public class DeleteClaimedOrganizationUserAccountCommandvNextTests +public class DeleteClaimedOrganizationUserAccountCommandTests { [Theory] [BitAutoData] public async Task DeleteUserAsync_WithValidSingleUser_CallsDeleteManyUsersAsync( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -65,7 +65,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithEmptyUserIds_ReturnsEmptyResults( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId) { @@ -77,7 +77,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -135,7 +135,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidationErrors_ReturnsErrorResults( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid orgUserId1, Guid orgUserId2, @@ -183,7 +183,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartialSuccessCorrectly( - SutProvider sutProvider, + SutProvider sutProvider, User validUser, Guid organizationId, Guid validOrgUserId, @@ -243,7 +243,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayExceptionAndLogsWarning( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -285,7 +285,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); - sutProvider.GetDependency>() + sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, @@ -299,7 +299,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task CreateInternalRequests_CreatesCorrectRequestsForAllUsers( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -326,7 +326,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(claimedStatuses); - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(callInfo => { @@ -338,7 +338,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.Sut.DeleteManyUsersAsync(organizationId, orgUserIds, deletingUserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateAsync(Arg.Is>(requests => requests.Count() == 2 && @@ -359,7 +359,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task GetUsersAsync_WithNullUserIds_ReturnsEmptyCollection( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId, [OrganizationUser] OrganizationUser orgUserWithoutUserId) @@ -374,7 +374,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests .GetManyAsync(Arg.Is>(ids => !ids.Any())) .Returns([]); - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(callInfo => { @@ -386,7 +386,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserWithoutUserId.Id], deletingUserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateAsync(Arg.Is>(requests => requests.Count() == 1 && @@ -406,7 +406,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests ValidationResultHelpers.Invalid(request, error); private static void SetupRepositoryMocks( - SutProvider sutProvider, + SutProvider sutProvider, ICollection orgUsers, IEnumerable users, Guid organizationId, @@ -426,16 +426,16 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests } private static void SetupValidatorMock( - SutProvider sutProvider, + SutProvider sutProvider, IEnumerable> validationResults) { - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(validationResults); } private static async Task AssertSuccessfulUserOperations( - SutProvider sutProvider, + SutProvider sutProvider, IEnumerable expectedUsers, IEnumerable expectedOrgUsers) { @@ -457,7 +457,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests events.Any(e => e.Item1.Id == expectedOrgUser.Id && e.Item2 == EventType.OrganizationUser_Deleted)))); } - private static async Task AssertNoUserOperations(SutProvider sutProvider) + private static async Task AssertNoUserOperations(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteManyAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushLogOutAsync(default); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs similarity index 97% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs index e51df6a626..30cc574ead 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -13,12 +13,12 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; [SutProviderCustomize] -public class DeleteClaimedOrganizationUserAccountValidatorvNextTests +public class DeleteClaimedOrganizationUserAccountValidatorTests { [Theory] [BitAutoData] public async Task ValidateAsync_WithValidSingleRequest_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -50,7 +50,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithMultipleValidRequests_ReturnsAllValidResults( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -97,7 +97,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithNullUser_ReturnsUserNotFoundError( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId, [OrganizationUser] OrganizationUser organizationUser) @@ -123,7 +123,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithNullOrganizationUser_ReturnsUserNotFoundError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId) @@ -149,7 +149,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithInvitedUser_ReturnsInvalidUserStatusError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -178,7 +178,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WhenDeletingYourself_ReturnsCannotDeleteYourselfError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, [OrganizationUser] OrganizationUser organizationUser) @@ -206,7 +206,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithUnclaimedUser_ReturnsUserNotClaimedError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -235,7 +235,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsNotOwner_ReturnsCannotDeleteOwnersError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -266,7 +266,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsOwner_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -296,7 +296,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithSoleOwnerOfOrganization_ReturnsSoleOwnerError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -331,7 +331,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithSoleProviderOwner_ReturnsSoleProviderError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -366,7 +366,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_CustomUserDeletingAdmin_ReturnsCannotDeleteAdminsError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -397,7 +397,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_AdminDeletingAdmin_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -427,7 +427,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithMixedValidAndInvalidRequests_ReturnsCorrespondingResults( - SutProvider sutProvider, + SutProvider sutProvider, User validUser, User invalidUser, Guid organizationId, @@ -475,7 +475,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests } private static void SetupMocks( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid userId, OrganizationUserType currentUserType = OrganizationUserType.Owner) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs deleted file mode 100644 index 7f1b101d7a..0000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs +++ /dev/null @@ -1,526 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.Context; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -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 NSubstitute; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; - -[SutProviderCustomize] -public class DeleteClaimedOrganizationUserAccountCommandTests -{ - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithValidUser_DeletesUserAndLogsEvent( - SutProvider sutProvider, User user, Guid deletingUserId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id))) - .Returns(new Dictionary { { organizationUser.Id, true } }); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id)), - includeProvider: Arg.Any()) - .Returns(true); - - // Act - await sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId); - - // Assert - await sutProvider.GetDependency().Received(1).DeleteAsync(user); - await sutProvider.GetDependency().Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithUserNotFound_ThrowsException( - SutProvider sutProvider, - Guid organizationId, Guid organizationUserId) - { - // Arrange - sutProvider.GetDependency() - .GetByIdAsync(organizationUserId) - .Returns((OrganizationUser?)null); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null)); - - // Assert - Assert.Equal("Member not found.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingYourself_ThrowsException( - SutProvider sutProvider, - User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id = deletingUserId; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("You cannot delete yourself.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException( - SutProvider sutProvider, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = null; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); - - // Assert - Assert.Equal("You cannot delete a member with Invited status.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationCustom(organizationUser.OrganizationId) - .Returns(true); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Custom users can not delete admins.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationOwner(organizationUser.OrganizationId) - .Returns(false); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Only owners can delete other owners.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationOwner(organizationUser.OrganizationId) - .Returns(true); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id)), - includeProvider: Arg.Any()) - .Returns(false); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Any>()) - .Returns(new Dictionary { { organizationUser.Id, false } }); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); - - // Assert - Assert.Equal("Member is not claimed by the organization.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, User user1, User user2, Guid organizationId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) - { - // Arrange - orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; - orgUser1.UserId = user1.Id; - orgUser2.UserId = user2.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser1, orgUser2 }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) - .Returns(new[] { user1, user2 }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) - .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); - - // Act - var userIds = new[] { orgUser1.Id, orgUser2.Id }; - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, userIds, null); - - // Assert - Assert.Equal(2, results.Count()); - Assert.All(results, r => Assert.Empty(r.Item2)); - - await sutProvider.GetDependency().Received(1).GetManyAsync(userIds); - await sutProvider.GetDependency().Received(1).DeleteManyAsync(Arg.Is>(users => users.Any(u => u.Id == user1.Id) && users.Any(u => u.Id == user2.Id))); - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( - Arg.Is>(events => - events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1 - && events.Count(e => e.Item1.Id == orgUser2.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserNotFound_ReturnsErrorMessage( - SutProvider sutProvider, - Guid organizationId, - Guid orgUserId) - { - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUserId }, null); - - // Assert - Assert.Single(result); - Assert.Equal(orgUserId, result.First().Item1); - Assert.Contains("Member not found.", result.First().Item2); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .DeleteManyAsync(default); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage( - SutProvider sutProvider, - User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id = deletingUserId; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new[] { user }); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("You cannot delete yourself.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage( - SutProvider sutProvider, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser) - { - // Arrange - orgUser.UserId = null; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("You cannot delete a member with Invited status.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .OrganizationOwner(orgUser.OrganizationId) - .Returns(false); - - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); - - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Only owners can delete other owners.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .OrganizationOwner(orgUser.OrganizationId) - .Returns(true); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>(), Arg.Any()) - .Returns(false); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Organization must have at least one confirmed owner.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser.UserId.Value))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(Arg.Any(), Arg.Any>()) - .Returns(new Dictionary { { orgUser.Id, false } }); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Member is not claimed by the organization.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults( - SutProvider sutProvider, User user1, User user3, - Guid organizationId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser2, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser3) - { - // Arrange - orgUser1.UserId = user1.Id; - orgUser2.UserId = null; - orgUser3.UserId = user3.Id; - orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organizationId; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser1, orgUser2, orgUser3 }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id))) - .Returns(new[] { user1, user3 }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) - .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser3.Id, false } }); - - // Act - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id, orgUser3.Id }, null); - - // Assert - Assert.Equal(3, results.Count()); - Assert.Empty(results.First(r => r.Item1 == orgUser1.Id).Item2); - Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); - Assert.Equal("Member is not claimed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( - Arg.Is>(events => - events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); - } -} From 179684a9e64b22a9aa2c0f77c5021e179824ea6e Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 25 Sep 2025 07:46:59 +0200 Subject: [PATCH 115/163] Begin pilot program for Claude code reviews with initial system prompt (#6371) * Rough draft of a markdown file to give context to Claude. --- CLAUDE.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..db0252ad8c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# Bitwarden Server - Claude Code Configuration + +## Critical Rules + +- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/`, generated migration files +- **Security First**: All code changes must prioritize cryptographic integrity and data protection +- **Test Coverage**: New features require xUnit unit tests with NSubstitute mocking +- **Check CODEOWNERS requirements**: The repo has a `.github/CODEOWNERS` file to define team ownership for different parts of the codebase. Respect that code owners have final authority over their designated areas + +## Project Context + +**Architecture**: CQRS pattern with feature-based organization +**Framework**: .NET 8.0, ASP.NET Core +**Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite +**Testing**: xUnit, NSubstitute +**Container**: Docker, Docker Compose, Kubernetes/Helm deployable + +## Development Standards + +### CQRS Pattern + +- Commands: `/src/Core/[Feature]/Commands/` +- Queries: `/src/Core/[Feature]/Queries/` +- Handlers implement `ICommandHandler` or `IQueryHandler` + +### API Conventions + +- RESTful endpoints with standard HTTP status codes +- Consistent error response: `{ "error": { "message": "..." } }` +- Pagination: `?skip=0&take=25` +- API versioning: `/api/v1/` + +### Database Migrations + +- **SQL Server**: Manual scripts in `/util/Migrator/DbScripts/` +- **Other DBs**: EF Core migrations via `pwsh ef_migrate.ps1` + +## Security Requirements + +- **Compliance**: SOC 2 Type II, SOC 3, HIPAA, ISO 27001, GDPR, CCPA +- **Principles**: Zero-knowledge, end-to-end encryption, secure defaults +- **Validation**: Input sanitization, parameterized queries, rate limiting +- **Logging**: Structured logs, no PII/sensitive data in logs + +## Code Review Checklist + +- Security impact assessed +- xUnit tests added/updated +- Performance impact considered +- Error handling implemented +- Breaking changes documented +- CI passes: build, test, lint + +## References + +- [Architecture](https://contributing.bitwarden.com/architecture/server/) +- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/) +- [Setup Guide](https://contributing.bitwarden.com/getting-started/server/guide/) +- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) +- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions) From 222436589c503917b18f4536f529525116a78c50 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 25 Sep 2025 12:37:29 -0400 Subject: [PATCH 116/163] Enhance Claude instructions (#6378) * Enhance Claude instructions * Further simplify language --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db0252ad8c..d07bd3f3e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,38 +2,30 @@ ## Critical Rules -- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/`, generated migration files -- **Security First**: All code changes must prioritize cryptographic integrity and data protection -- **Test Coverage**: New features require xUnit unit tests with NSubstitute mocking -- **Check CODEOWNERS requirements**: The repo has a `.github/CODEOWNERS` file to define team ownership for different parts of the codebase. Respect that code owners have final authority over their designated areas +- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files +- **NEVER** use code regions: If complexity suggests regions, refactor for better readability +- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden +- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages +- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity +- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use +- **ALWAYS** prioritize cryptographic integrity and data protection +- **ALWAYS** add unit tests (with mocking) for any new feature development ## Project Context -**Architecture**: CQRS pattern with feature-based organization -**Framework**: .NET 8.0, ASP.NET Core -**Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite -**Testing**: xUnit, NSubstitute -**Container**: Docker, Docker Compose, Kubernetes/Helm deployable +- **Architecture**: Feature and team-based organization +- **Framework**: .NET 8.0, ASP.NET Core +- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite +- **Testing**: xUnit, NSubstitute +- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable -## Development Standards +## Project Structure -### CQRS Pattern - -- Commands: `/src/Core/[Feature]/Commands/` -- Queries: `/src/Core/[Feature]/Queries/` -- Handlers implement `ICommandHandler` or `IQueryHandler` - -### API Conventions - -- RESTful endpoints with standard HTTP status codes -- Consistent error response: `{ "error": { "message": "..." } }` -- Pagination: `?skip=0&take=25` -- API versioning: `/api/v1/` - -### Database Migrations - -- **SQL Server**: Manual scripts in `/util/Migrator/DbScripts/` -- **Other DBs**: EF Core migrations via `pwsh ef_migrate.ps1` +- **Source Code**: `/src/` - Services and core infrastructure +- **Tests**: `/test/` - Test logic aligning with the source structure, albeit with a `.Test` suffix +- **Utilities**: `/util/` - Migration tools, seeders, and setup scripts +- **Dev Tools**: `/dev/` - Local development helpers +- **Configuration**: `appsettings.{Environment}.json`, `/dev/secrets.json` for local development ## Security Requirements @@ -42,20 +34,39 @@ - **Validation**: Input sanitization, parameterized queries, rate limiting - **Logging**: Structured logs, no PII/sensitive data in logs +## Common Commands + +- **Build**: `dotnet build` +- **Test**: `dotnet test` +- **Run locally**: `dotnet run --project src/Api` +- **Database update**: `pwsh dev/migrate.ps1` +- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1` + ## Code Review Checklist - Security impact assessed -- xUnit tests added/updated +- xUnit tests added / updated - Performance impact considered - Error handling implemented - Breaking changes documented - CI passes: build, test, lint +- Feature flags considered for new features +- CODEOWNERS file respected + +### Key Architectural Decisions + +- Use .NET nullable reference types (ADR 0024) +- TryAdd dependency injection pattern (ADR 0026) +- Authorization patterns (ADR 0022) +- OpenTelemetry for observability (ADR 0020) +- Log to standard output (ADR 0021) ## References -- [Architecture](https://contributing.bitwarden.com/architecture/server/) -- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/) -- [Setup Guide](https://contributing.bitwarden.com/getting-started/server/guide/) -- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Server architecture](https://contributing.bitwarden.com/architecture/server/) +- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/) +- [Contributing guidelines](https://contributing.bitwarden.com/contributing/) +- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/) +- [Code style](https://contributing.bitwarden.com/contributing/code-style/) - [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) - [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions) From 6466c00acde2d067115c6274c21e2dbad852f1d0 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:37:36 -0400 Subject: [PATCH 117/163] fix(user-decryption-options) [PM-23174]: ManageAccountRecovery Permission Forces Master Password Set (#6230) * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Update tests, add OrganizationUser fixture customization for Permissions * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Update hasManageResetPasswordPermission evaluation. * PM-23174 - Add TODO for endpoint per sync discussion with Dave * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Clean up comments. * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Remove an outdated comment. * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Elaborate on comments around Organization User invite-time evaluation. * fix(user-decryption-options): Use currentContext for Provider relationships, update comments, and feature flag the change. * fix(user-decryption-options): Update test suite and provide additional comments for future flag removal. --------- Co-authored-by: Jared Snider --- src/Core/Constants.cs | 2 + .../UserDecryptionOptionsBuilder.cs | 104 ++++++++++++++---- .../AutoFixture/OrganizationUserFixtures.cs | 26 +++++ .../UserDecryptionOptionsBuilderTests.cs | 71 ++++++++++-- 4 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 97c1399719..ba8c1a84cd 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,8 @@ public static class FeatureFlagKeys public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; + public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = + "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index dc27842210..136c3f7298 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Utilities; @@ -7,6 +8,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -24,6 +26,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder private readonly IDeviceRepository _deviceRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; + private readonly IFeatureService _featureService; private UserDecryptionOptions _options = new UserDecryptionOptions(); private User _user = null!; @@ -34,13 +37,15 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder ICurrentContext currentContext, IDeviceRepository deviceRepository, IOrganizationUserRepository organizationUserRepository, - ILoginApprovingClientTypes loginApprovingClientTypes + ILoginApprovingClientTypes loginApprovingClientTypes, + IFeatureService featureService ) { _currentContext = currentContext; _deviceRepository = deviceRepository; _organizationUserRepository = organizationUserRepository; _loginApprovingClientTypes = loginApprovingClientTypes; + _featureService = featureService; } public IUserDecryptionOptionsBuilder ForUser(User user) @@ -65,8 +70,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder { if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) { - _options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); + _options.WebAuthnPrfOption = + new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); } + return this; } @@ -74,7 +81,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder { BuildMasterPasswordUnlock(); BuildKeyConnectorOptions(); - await BuildTrustedDeviceOptions(); + await BuildTrustedDeviceOptionsAsync(); return _options; } @@ -87,13 +94,14 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder } var ssoConfigurationData = _ssoConfig.GetData(); - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) + if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && + !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) { _options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); } } - private async Task BuildTrustedDeviceOptions() + private async Task BuildTrustedDeviceOptionsAsync() { // TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change if (_ssoConfig == null) @@ -101,7 +109,8 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder return; } - var isTdeActive = _ssoConfig.GetData() is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; + var isTdeActive = _ssoConfig.GetData() is + { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; var isTdeOffboarding = !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; if (!isTdeActive && !isTdeOffboarding) { @@ -120,25 +129,51 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder if (_device != null) { var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id); - // Checks if the current user has any devices that are capable of approving login with device requests except for - // their current device. - // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. - hasLoginApprovingDevice = allDevices.Any(d => d.Identifier != _device.Identifier && _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type))); + // Checks if the current user has any devices that are capable of approving login with device requests + // except for their current device. + hasLoginApprovingDevice = allDevices.Any(d => + d.Identifier != _device.Identifier && + _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type))); } - // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP - var hasManageResetPasswordPermission = false; - // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here - if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) - { - // TDE requires single org so grabbing first org & id is fine. - hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); - } - - // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + // Just-in-time-provisioned users, which can include users invited to a TDE organization with SSO and granted + // the Admin/Owner role or Custom user role with ManageResetPassword permission, will not have claims available + // in context to reflect this permission if granted as part of an invite for the current organization. + // Therefore, as written today, CurrentContext will not surface those permissions for those users. + // In order to make this check accurate at first login for all applicable cases, we have to go back to the + // database record. + // In the TDE flow, the users will have been JIT-provisioned at SSO callback time, and the relationship between + // user and organization user will have been codified. var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + var hasManageResetPasswordPermission = false; + if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword)) + { + hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); + } + else + { + // TODO: PM-26065 remove use of above feature flag from the server, and remove this branching logic, which + // has been replaced by EvaluateHasManageResetPasswordPermission. + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP. + // When removing feature flags, please also see notes and removals intended for test suite in + // Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue. + + // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here + if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) + { + // TDE requires single org so grabbing first org & id is fine. + hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); + } + + // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + + // NOTE: Commented from original impl because the organization user repository call has been hoisted to support + // branching paths through flagging. + //organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + + hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); + } - hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); // They are only able to be approved by an admin if they have enrolled is reset password var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); @@ -149,6 +184,31 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder isTdeOffboarding, encryptedPrivateKey, encryptedUserKey); + return; + + async Task EvaluateHasManageResetPasswordPermission() + { + // PM-23174 + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP + if (organizationUser == null) + { + return false; + } + + var organizationUserHasResetPasswordPermission = + // The repository will pull users in all statuses, so we also need to ensure that revoked-status users do not have + // permissions sent down. + organizationUser.Status is OrganizationUserStatusType.Invited or OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed && + // Admins and owners get ManageResetPassword functionally "for free" through their role. + (organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner || + // Custom users can have the ManagePasswordReset permission assigned directly. + organizationUser.GetPermissions() is { ManageResetPassword: true }); + + return organizationUserHasResetPasswordPermission || + // A provider user for the given organization gets ManageResetPassword through that relationship. + await _currentContext.ProviderUserForOrgAsync(_ssoConfig.OrganizationId); + } } private void BuildMasterPasswordUnlock() diff --git a/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs b/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs new file mode 100644 index 0000000000..e4d40601c5 --- /dev/null +++ b/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Identity.Test.AutoFixture; + +internal class OrganizationUserWithDefaultPermissionsCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + // On OrganizationUser, Permissions can be JSON data (as string) or sometimes null. + // Entity APIs should prevent it from being anything else. + // An un-modified fixture for OrganizationUser will return a bare string Permissions{guid} + // in the member, throwing a JsonException on deserialization of a bare string. + .With(organizationUser => organizationUser.Permissions, CoreHelpers.ClassToJsonData(new Permissions()))); + } +} + +public class OrganizationUserWithDefaultPermissionsAttribute : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) => new OrganizationUserWithDefaultPermissionsCustomization(); +} diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index b44dfe8d5f..37e88b0ec0 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -1,15 +1,20 @@ -using Bit.Core.Auth.Entities; +using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Identity.IdentityServer; +using Bit.Identity.Test.AutoFixture; using Bit.Identity.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using User = Bit.Core.Entities.User; namespace Bit.Identity.Test.IdentityServer; @@ -20,6 +25,7 @@ public class UserDecryptionOptionsBuilderTests private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; private readonly UserDecryptionOptionsBuilder _builder; + private readonly IFeatureService _featureService; public UserDecryptionOptionsBuilderTests() { @@ -27,7 +33,8 @@ public class UserDecryptionOptionsBuilderTests _deviceRepository = Substitute.For(); _organizationUserRepository = Substitute.For(); _loginApprovingClientTypes = Substitute.For(); - _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes); + _featureService = Substitute.For(); + _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes, _featureService); var user = new User(); _builder.ForUser(user); } @@ -220,19 +227,65 @@ public class UserDecryptionOptionsBuilderTests Assert.False(result.TrustedDeviceOption?.HasLoginApprovingDevice); } - [Theory, BitAutoData] + /// + /// This logic has been flagged as part of PM-23174. + /// When removing the server flag, please also remove this test, and remove the FeatureService + /// dependency from this suite and the following test. + /// + /// + /// + /// + /// + /// + /// + [Theory] + [BitAutoData(OrganizationUserType.Custom)] public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue( + OrganizationUserType organizationUserType, SsoConfig ssoConfig, SsoConfigurationData configurationData, - CurrentContextOrganization organization) + CurrentContextOrganization organization, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; ssoConfig.Data = configurationData.Serialize(); ssoConfig.OrganizationId = organization.Id; - _currentContext.Organizations.Returns(new List(new CurrentContextOrganization[] { organization })); + _currentContext.Organizations.Returns([organization]); _currentContext.ManageResetPassword(organization.Id).Returns(true); + organizationUser.Type = organizationUserType; + organizationUser.OrganizationId = organization.Id; + organizationUser.UserId = user.Id; + organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true }); + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); - var result = await _builder.WithSso(ssoConfig).BuildAsync(); + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); + } + + [Theory] + [BitAutoData(OrganizationUserType.Custom)] + public async Task Build_WhenManageResetPasswordPermissions_ShouldFetchUserFromRepositoryAndReturnHasManageResetPasswordPermissionTrue( + OrganizationUserType organizationUserType, + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + CurrentContextOrganization organization, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) + { + _featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword) + .Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + ssoConfig.OrganizationId = organization.Id; + organizationUser.Type = organizationUserType; + organizationUser.OrganizationId = organization.Id; + organizationUser.UserId = user.Id; + organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true }); + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); } @@ -241,7 +294,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenIsOwnerInvite_ShouldReturnHasManageResetPasswordPermissionTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; @@ -258,7 +311,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenIsAdminInvite_ShouldReturnHasManageResetPasswordPermissionTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; @@ -275,7 +328,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenUserHasEnrolledIntoPasswordReset_ShouldReturnHasAdminApprovalTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; From 0df22ff5816ac22d6fe5b669fe83c874cc5721a6 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 25 Sep 2025 19:05:48 -0400 Subject: [PATCH 118/163] null coalesce collections to an empty array (#6381) --- .../Public/Models/Request/MemberCreateRequestModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index 6813610325..b3182601b5 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -31,7 +31,7 @@ public class MemberCreateRequestModel : MemberUpdateRequestModel { Emails = new[] { Email }, Type = Type.Value, - Collections = Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(), + Collections = Collections?.Select(c => c.ToCollectionAccessSelection())?.ToList() ?? [], Groups = Groups }; From ef54bc814d5dfc4495e0b99de4d5adbb1d2a6d54 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 26 Sep 2025 15:46:57 +0200 Subject: [PATCH 119/163] Fix a couple broken links found during self-onboarding (#6386) * Fix a couple broken links found during self-onboarding --- util/Migrator/README.md | 2 +- util/MySqlMigrations/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/util/Migrator/README.md b/util/Migrator/README.md index 950c2b682e..81c8bd3ae2 100644 --- a/util/Migrator/README.md +++ b/util/Migrator/README.md @@ -4,4 +4,4 @@ A class library leveraged by [utilities](../MsSqlMigratorUtility) and [hosted ap In production environments the Migrator is typically executed during application startup or as part of CI/CD pipelines to ensure database schemas are up-to-date before application deployment. -See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/) for how to utilize the files seen here. +See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/mssql/) for how to utilize the files seen here. diff --git a/util/MySqlMigrations/README.md b/util/MySqlMigrations/README.md index 3373aabaee..1cace0c610 100644 --- a/util/MySqlMigrations/README.md +++ b/util/MySqlMigrations/README.md @@ -2,4 +2,4 @@ A class library leveraged by [hosted applications](/src/Admin/HostedServices/DatabaseMigrationHostedService.cs) to perform MySQL database migrations via Entity Framework. -See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/) for how to utilize the files seen here. +See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/ef/) for how to utilize the files seen here. From 9e0b767c98c6cdba5388fbf593f29891308300b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:56:28 -0500 Subject: [PATCH 120/163] [deps] Billing: Update CsvHelper to 33.1.0 (#6042) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> --- bitwarden_license/src/Commercial.Core/Commercial.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj index 57babb4043..9209917d1e 100644 --- a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj +++ b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj @@ -5,7 +5,7 @@ - + From 80e7f4d85c0978e6108908c35bdc2fc2c760904e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:05:23 +0000 Subject: [PATCH 121/163] [deps] Billing: Update BenchmarkDotNet to 0.15.3 (#6041) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> --- perf/MicroBenchmarks/MicroBenchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perf/MicroBenchmarks/MicroBenchmarks.csproj b/perf/MicroBenchmarks/MicroBenchmarks.csproj index 82c526a7d2..a13792b2d6 100644 --- a/perf/MicroBenchmarks/MicroBenchmarks.csproj +++ b/perf/MicroBenchmarks/MicroBenchmarks.csproj @@ -7,7 +7,7 @@ - + From b9e8b11311230b1176a161ebf194569cc8637279 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:05:56 -0700 Subject: [PATCH 122/163] update collections admin proc and repo (#6374) --- .../CollectionCipherRepository.cs | 13 ++- .../CollectionCipher_UpdateCollections.sql | 4 +- ...ollectionCipher_UpdateCollectionsAdmin.sql | 80 ++++++++++--------- ...ollectionCipher_UpdateCollectionsAdmin.sql | 55 +++++++++++++ 4 files changed, 109 insertions(+), 43 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index 6e2805f987..39e3ab8019 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using Microsoft.EntityFrameworkCore; @@ -145,9 +146,11 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var availableCollections = await (from c in dbContext.Collections - where c.OrganizationId == organizationId - select c).ToListAsync(); + + var availableCollectionIds = await (from c in dbContext.Collections + where c.OrganizationId == organizationId + && c.Type != CollectionType.DefaultUserCollection + select c.Id).ToListAsync(); var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers where cc.CipherId == cipherId @@ -155,6 +158,8 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec foreach (var requestedCollectionId in collectionIds) { + if (!availableCollectionIds.Contains(requestedCollectionId)) continue; + var requestedCollectionCipher = currentCollectionCiphers .FirstOrDefault(cc => cc.CollectionId == requestedCollectionId); @@ -168,7 +173,7 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } - dbContext.RemoveRange(currentCollectionCiphers.Where(cc => !collectionIds.Contains(cc.CollectionId))); + dbContext.RemoveRange(currentCollectionCiphers.Where(cc => availableCollectionIds.Contains(cc.CollectionId) && !collectionIds.Contains(cc.CollectionId))); await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql index f3a1d964b5..2282524228 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql @@ -44,13 +44,13 @@ BEGIN [CollectionId], [CipherId] ) - SELECT + SELECT [Id], @CipherId FROM @CollectionIds WHERE [Id] IN (SELECT [Id] FROM [#TempAvailableCollections]) AND NOT EXISTS ( - SELECT 1 + SELECT 1 FROM [dbo].[CollectionCipher] WHERE [CollectionId] = [@CollectionIds].[Id] AND [CipherId] = @CipherId diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql index 5f7b0215d9..1486709f09 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql @@ -4,46 +4,52 @@ @CollectionIds AS [dbo].[GuidIdArray] READONLY AS BEGIN - SET NOCOUNT ON + SET NOCOUNT ON; - ;WITH [AvailableCollectionsCTE] AS( - SELECT - Id - FROM - [dbo].[Collection] - WHERE - OrganizationId = @OrganizationId - ), - [CollectionCiphersCTE] AS( - SELECT - [CollectionId], - [CipherId] - FROM - [dbo].[CollectionCipher] - WHERE - [CipherId] = @CipherId + -- Available collections for this org, excluding default collections + SELECT + C.[Id] + INTO #TempAvailableCollections + FROM [dbo].[Collection] AS C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] <> 1; -- exclude DefaultUserCollection + + -- Insert new collection assignments + INSERT INTO [dbo].[CollectionCipher] ( + [CollectionId], + [CipherId] ) - MERGE - [CollectionCiphersCTE] AS [Target] - USING - @CollectionIds AS [Source] - ON - [Target].[CollectionId] = [Source].[Id] - AND [Target].[CipherId] = @CipherId - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN - INSERT VALUES - ( - [Source].[Id], - @CipherId - ) - WHEN NOT MATCHED BY SOURCE - AND [Target].[CipherId] = @CipherId THEN - DELETE - ; + SELECT + S.[Id], + @CipherId + FROM @CollectionIds AS S + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = S.[Id] + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionCipher] AS CC + WHERE CC.[CollectionId] = S.[Id] + AND CC.[CipherId] = @CipherId + ); + + -- Delete removed collection assignments + DELETE CC + FROM [dbo].[CollectionCipher] AS CC + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = CC.[CollectionId] + WHERE CC.[CipherId] = @CipherId + AND NOT EXISTS ( + SELECT 1 + FROM @CollectionIds AS S + WHERE S.[Id] = CC.[CollectionId] + ); IF @OrganizationId IS NOT NULL BEGIN - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; END -END \ No newline at end of file + + DROP TABLE #TempAvailableCollections; +END +GO diff --git a/util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql b/util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql new file mode 100644 index 0000000000..f69fdb30ff --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql @@ -0,0 +1,55 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_UpdateCollectionsAdmin] + @CipherId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON; + + -- Available collections for this org, excluding default collections + SELECT + C.[Id] + INTO #TempAvailableCollections + FROM [dbo].[Collection] AS C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] <> 1; -- exclude DefaultUserCollection + + -- Insert new collection assignments + INSERT INTO [dbo].[CollectionCipher] ( + [CollectionId], + [CipherId] + ) + SELECT + S.[Id], + @CipherId + FROM @CollectionIds AS S + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = S.[Id] + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionCipher] AS CC + WHERE CC.[CollectionId] = S.[Id] + AND CC.[CipherId] = @CipherId + ); + + -- Delete removed collection assignments + DELETE CC + FROM [dbo].[CollectionCipher] AS CC + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = CC.[CollectionId] + WHERE CC.[CipherId] = @CipherId + AND NOT EXISTS ( + SELECT 1 + FROM @CollectionIds AS S + WHERE S.[Id] = CC.[CollectionId] + ); + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; + END + + DROP TABLE #TempAvailableCollections; +END +GO From 3a6b9564d5eda550dabdbd81b864382a6d67bdfa Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 26 Sep 2025 14:30:34 -0700 Subject: [PATCH 123/163] [PM-26004] - fix DeleteByOrganizationIdAsync_ExcludesDefaultCollectionCiphers test (#6389) * fix test * fix test --- .../Vault/Repositories/CipherRepositoryTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index ee8cd0247d..3a44453ed6 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1290,12 +1290,16 @@ public class CipherRepositoryTests var cipherInBothCollections = await CreateOrgCipherAsync(); var unassignedCipher = await CreateOrgCipherAsync(); - await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInDefaultCollection.Id, organization.Id, - new List { defaultCollection.Id }); - await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection.Id, organization.Id, - new List { sharedCollection.Id }); - await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInBothCollections.Id, organization.Id, - new List { defaultCollection.Id, sharedCollection.Id }); + async Task LinkCollectionCipherAsync(Guid cipherId, Guid collectionId) => + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { cipherId }, + new[] { collectionId }); + + await LinkCollectionCipherAsync(cipherInDefaultCollection.Id, defaultCollection.Id); + await LinkCollectionCipherAsync(cipherInSharedCollection.Id, sharedCollection.Id); + await LinkCollectionCipherAsync(cipherInBothCollections.Id, defaultCollection.Id); + await LinkCollectionCipherAsync(cipherInBothCollections.Id, sharedCollection.Id); await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); @@ -1402,3 +1406,4 @@ public class CipherRepositoryTests Assert.Empty(remainingCollectionCiphers); } } + From 3dd4ee7a074b6ccf78ae40d519322e7a277baa8d Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 29 Sep 2025 08:31:56 +0200 Subject: [PATCH 124/163] Create new Action for Claude code review of Vault Team code (#6379) Create new action for Claude Code Review of Vault Team Code. Worked to align what we have here with the initial `mcp-server` repo's code review action. --- .github/workflows/review-code.yml | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/review-code.yml diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml new file mode 100644 index 0000000000..b49f5cec8f --- /dev/null +++ b/.github/workflows/review-code.yml @@ -0,0 +1,109 @@ +name: Review code + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + review: + name: Review + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + pull-requests: write + + steps: + - name: Check out repo + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check for Vault team changes + id: check_changes + run: | + # Ensure we have the base branch + git fetch origin ${{ github.base_ref }} + + echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + if [ -z "$CHANGED_FILES" ]; then + echo "Zero files changed" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Handle variations in spacing and multiple teams + VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}') + + if [ -z "$VAULT_PATTERNS" ]; then + echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + vault_team_changes=false + for pattern in $VAULT_PATTERNS; do + echo "Checking pattern: $pattern" + + # Handle **/directory patterns + if [[ "$pattern" == "**/"* ]]; then + # Remove the **/ prefix + dir_pattern="${pattern#\*\*/}" + # Check if any file contains this directory in its path + if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /' + break + fi + else + # Handle other patterns (shouldn't happen based on your CODEOWNERS) + if echo "$CHANGED_FILES" | grep -q "$pattern"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /' + break + fi + fi + done + + echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT + + if [ "$vault_team_changes" = "true" ]; then + echo "" + echo "✅ Vault team changes detected - proceeding with review" + else + echo "" + echo "❌ No Vault team changes detected - skipping review" + fi + + - name: Review with Claude Code + if: steps.check_changes.outputs.vault_team_changes == 'true' + uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + track_progress: true + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + BODY: ${{ github.event.pull_request.body }} + AUTHOR: ${{ github.event.pull_request.user.login }} + + Please review this pull request with a focus on: + - Code quality and best practices + - Potential bugs or issues + - Security implications + - Performance considerations + + Note: The PR branch is already checked out in the current working directory. + + Provide detailed feedback using inline comments for specific issues. + + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" From a36340e9ad08920e85bed9c200135158fa1ed9f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:17:13 -0400 Subject: [PATCH 125/163] [deps]: Update prettier to v3.6.2 (#6212) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/MailTemplates/Mjml/package-lock.json | 8 ++++---- src/Core/MailTemplates/Mjml/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json index a78405676f..df85185af9 100644 --- a/src/Core/MailTemplates/Mjml/package-lock.json +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "nodemon": "3.1.10", - "prettier": "3.5.3" + "prettier": "3.6.2" } }, "node_modules/@babel/runtime": { @@ -1564,9 +1564,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { diff --git a/src/Core/MailTemplates/Mjml/package.json b/src/Core/MailTemplates/Mjml/package.json index c3690a2d73..8a8f81e845 100644 --- a/src/Core/MailTemplates/Mjml/package.json +++ b/src/Core/MailTemplates/Mjml/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "nodemon": "3.1.10", - "prettier": "3.5.3" + "prettier": "3.6.2" } } From e0ccd7f578e44f2eb31ec16d243ff95ce46ba29f Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Mon, 29 Sep 2025 13:06:52 -0400 Subject: [PATCH 126/163] chore(global-settings): [PM-24717] New Global Settings For New Device Verification - Updated secrets in the example secrets.json (#6387) --- dev/secrets.json.example | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/secrets.json.example b/dev/secrets.json.example index 7c91669b39..c6a16846e9 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -33,6 +33,8 @@ "id": "", "key": "" }, - "licenseDirectory": "" + "licenseDirectory": "", + "enableNewDeviceVerification": true, + "enableEmailVerification": true } } From f1af331a0c804d7f036f847e2fe4c274d700b082 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 29 Sep 2025 13:22:39 -0400 Subject: [PATCH 127/163] remove feature flag (#6395) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ba8c1a84cd..44760c56a4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -133,7 +133,6 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; 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"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; From 46958cc838c43a8e702e6856eb30ea6250fdd222 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:06:52 -0500 Subject: [PATCH 128/163] [PM-25982] Restrict Ciphers being assigned to Default from Shared collections (#6382) * validate that any change in collection does not allow only shared ciphers to migrate to a default cipher * refactor order of checks to avoid any unnecessary calls * remove unneeded conditional --- .../Vault/Controllers/CiphersController.cs | 3 ++ src/Core/Vault/Services/ICipherService.cs | 1 + .../Services/Implementations/CipherService.cs | 50 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index c0a974bce2..06c88ad9bb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -887,6 +887,9 @@ public class CiphersController : Controller [HttpPost("bulk-collections")] public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) { + var userId = _userService.GetProperUserId(User).Value; + await _cipherService.ValidateBulkCollectionAssignmentAsync(model.CollectionIds, model.CipherIds, userId); + if (!await CanModifyCipherCollectionsAsync(model.OrganizationId, model.CipherIds) || !await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds)) { diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index dac535433c..ffd79e9381 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -37,4 +37,5 @@ public interface ICipherService Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); + Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index ebfb2a4a2a..35b745fce6 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -170,6 +170,7 @@ public class CipherService : ICipherService { ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; + await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); await ValidateViewPasswordUserAsync(cipher); await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_Updated); @@ -539,6 +540,7 @@ public class CipherService : ICipherService try { await ValidateCipherCanBeShared(cipher, sharingUserId, organizationId, lastKnownRevisionDate); + await ValidateChangeInCollectionsAsync(cipher, collectionIds, sharingUserId); // Sproc will not save this UserId on the cipher. It is used limit scope of the collectionIds. cipher.UserId = sharingUserId; @@ -678,6 +680,7 @@ public class CipherService : ICipherService { throw new BadRequestException("Cipher must belong to an organization."); } + await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); cipher.RevisionDate = DateTime.UtcNow; @@ -820,6 +823,15 @@ public class CipherService : ICipherService return restoringCiphers; } + public async Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId) + { + foreach (var cipherId in cipherIds) + { + var cipher = await _cipherRepository.GetByIdAsync(cipherId); + await ValidateChangeInCollectionsAsync(cipher, collectionIds, userId); + } + } + private async Task UserCanEditAsync(Cipher cipher, Guid userId) { if (!cipher.OrganizationId.HasValue && cipher.UserId.HasValue && cipher.UserId.Value == userId) @@ -1038,6 +1050,44 @@ public class CipherService : ICipherService } } + // Validates that a cipher is not being added to a default collection when it is only currently only in shared collections + private async Task ValidateChangeInCollectionsAsync(Cipher updatedCipher, IEnumerable newCollectionIds, Guid userId) + { + + if (updatedCipher.Id == Guid.Empty || !updatedCipher.OrganizationId.HasValue) + { + return; + } + + var currentCollectionsForCipher = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, updatedCipher.Id); + + if (!currentCollectionsForCipher.Any()) + { + // When a cipher is not currently in any collections it can be assigned to any type of collection + return; + } + + var currentCollections = await _collectionRepository.GetManyByManyIdsAsync(currentCollectionsForCipher.Select(c => c.CollectionId)); + + var currentCollectionsContainDefault = currentCollections.Any(c => c.Type == CollectionType.DefaultUserCollection); + + // When the current cipher already contains the default collection, no check is needed for if they added or removed + // a default collection, because it is already there. + if (currentCollectionsContainDefault) + { + return; + } + + var newCollections = await _collectionRepository.GetManyByManyIdsAsync(newCollectionIds); + var newCollectionsContainDefault = newCollections.Any(c => c.Type == CollectionType.DefaultUserCollection); + + if (newCollectionsContainDefault) + { + // User is trying to add the default collection when the cipher is only in shared collections + throw new BadRequestException("The cipher(s) cannot be assigned to a default collection when only assigned to non-default collections."); + } + } + private string SerializeCipherData(CipherData data) { return data switch From ca3d05c723c6b5a26284630b4017d3296cddfe91 Mon Sep 17 00:00:00 2001 From: Tyler <71953103+fntyler@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:40:20 -0400 Subject: [PATCH 129/163] BRE-1040 Dockerfiles shared ownership (#6257) * Include AppSec team and BRE dept for repository-level ownership of Dockerfile, and Dockerfile related, files. --- .github/CODEOWNERS | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f1c5f18fb..6db4905fec 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,11 +4,12 @@ # # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -## Docker files have shared ownership ## -**/Dockerfile -**/*.Dockerfile -**/.dockerignore -**/entrypoint.sh +## Docker-related files +**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre +**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre +**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre +**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre +**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre ## BRE team owns these workflows ## .github/workflows/publish.yml @bitwarden/dept-bre From f6b99a7906c6e9f50b498c55552fe75c6c3643d5 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:00:09 +0200 Subject: [PATCH 130/163] adds `pm-23995-no-logout-on-kdf-change` feature flag (#6397) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 44760c56a4..d26e0f67fa 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -186,6 +186,7 @@ public static class FeatureFlagKeys public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; + public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; From 87849077365934743536d68685aee37a80b83532 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:29:18 -0700 Subject: [PATCH 131/163] chore(flag-removal): [Auth/PM20439] Remove Flagging Logic for BrowserExtensionLoginApproval (#6368) --- .../Controllers/AuthRequestsController.cs | 3 -- .../Utilities/LoginApprovingClientTypes.cs | 31 +++++-------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index 4da3a2f491..e9dfe17c94 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -3,7 +3,6 @@ using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.AuthRequest; @@ -12,7 +11,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -55,7 +53,6 @@ public class AuthRequestsController( } [HttpGet("pending")] - [RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)] public async Task> GetPendingAuthRequestsAsync() { var userId = _userService.GetProperUserId(User).Value; diff --git a/src/Identity/Utilities/LoginApprovingClientTypes.cs b/src/Identity/Utilities/LoginApprovingClientTypes.cs index f0c7b831b7..28049ed16b 100644 --- a/src/Identity/Utilities/LoginApprovingClientTypes.cs +++ b/src/Identity/Utilities/LoginApprovingClientTypes.cs @@ -1,6 +1,4 @@ -using Bit.Core; -using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Enums; namespace Bit.Identity.Utilities; @@ -11,28 +9,15 @@ public interface ILoginApprovingClientTypes public class LoginApprovingClientTypes : ILoginApprovingClientTypes { - public LoginApprovingClientTypes( - IFeatureService featureService) + public LoginApprovingClientTypes() { - if (featureService.IsEnabled(FeatureFlagKeys.BrowserExtensionLoginApproval)) + TypesThatCanApprove = new List { - TypesThatCanApprove = new List - { - ClientType.Desktop, - ClientType.Mobile, - ClientType.Web, - ClientType.Browser, - }; - } - else - { - TypesThatCanApprove = new List - { - ClientType.Desktop, - ClientType.Mobile, - ClientType.Web, - }; - } + ClientType.Desktop, + ClientType.Mobile, + ClientType.Web, + ClientType.Browser, + }; } public IReadOnlyCollection TypesThatCanApprove { get; } From 718d96cc58d45f2b59ca7080da814a27f1bc7abf Mon Sep 17 00:00:00 2001 From: Alexey Zilber <110793805+alex8bitw@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:28:30 +0800 Subject: [PATCH 132/163] Increased usable port range for ephemeral ports from 26,669 to 59,976 (#6394) --- src/Notifications/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Notifications/Dockerfile b/src/Notifications/Dockerfile index 1b0b507606..031df0b1b6 100644 --- a/src/Notifications/Dockerfile +++ b/src/Notifications/Dockerfile @@ -57,6 +57,8 @@ RUN apk add --no-cache curl \ WORKDIR /app COPY --from=build /source/src/Notifications/out /app COPY ./src/Notifications/entrypoint.sh /entrypoint.sh +RUN echo "net.ipv4.ip_local_port_range = 5024 65000" >> /etc/sysctl.d/99-sysctl.conf +RUN echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.d/99-sysctl.conf RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 From fc07dec3a69ae96f69c15f43bba37c35e9b7ee47 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Tue, 30 Sep 2025 07:43:43 -0700 Subject: [PATCH 133/163] PM-25915 tools exclude items in my items collections and my items collection from org vault export endpoint (#6362) Exclude MyItems and MyItems collection from Organizational Exports when CreateDefaultLocation feature flag is enabled --- .../OrganizationExportController.cs | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index b1925dd3cf..dd039bc4a5 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,5 +1,6 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -20,19 +21,22 @@ public class OrganizationExportController : Controller private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; + private readonly IFeatureService _featureService; public OrganizationExportController( IUserService userService, GlobalSettings globalSettings, IAuthorizationService authorizationService, IOrganizationCiphersQuery organizationCiphersQuery, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IFeatureService featureService) { _userService = userService; _globalSettings = globalSettings; _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; + _featureService = featureService; } [HttpGet("export")] @@ -40,23 +44,47 @@ public class OrganizationExportController : Controller { var canExportAll = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportWholeVault); - if (canExportAll.Succeeded) - { - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); - var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); - return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, _globalSettings)); - } - var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportManagedCollections); + var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation); + + if (canExportAll.Succeeded) + { + if (createDefaultLocationEnabled) + { + var allOrganizationCiphers = + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections( + organizationId); + + var allCollections = await _collectionRepository + .GetManySharedCollectionsByOrganizationIdAsync( + organizationId); + + + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); + } + else + { + var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + + var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); + + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); + } + } + if (canExportManaged.Succeeded) { var userId = _userService.GetProperUserId(User)!.Value; var allUserCollections = await _collectionRepository.GetManyByUserIdAsync(userId); - var managedOrgCollections = allUserCollections.Where(c => c.OrganizationId == organizationId && c.Manage).ToList(); - var managedCiphers = - await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, managedOrgCollections.Select(c => c.Id)); + var managedOrgCollections = + allUserCollections.Where(c => c.OrganizationId == organizationId && c.Manage).ToList(); + + var managedCiphers = await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, + managedOrgCollections.Select(c => c.Id)); return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings)); } From 12303b3acf273e87acd681a701495cea970e5dc3 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:04:11 -0500 Subject: [PATCH 134/163] When deleting an archived clear the archived date so it will be restored to the vault (#6398) --- src/Core/Vault/Services/Implementations/CipherService.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 35b745fce6..ca6bacd55a 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -721,6 +721,13 @@ public class CipherService : ICipherService cipherDetails.DeletedDate = cipherDetails.RevisionDate = DateTime.UtcNow; + if (cipherDetails.ArchivedDate.HasValue) + { + // If the cipher was archived, clear the archived date when soft deleting + // If a user were to restore an archived cipher, it should go back to the vault not the archive vault + cipherDetails.ArchivedDate = null; + } + await _cipherRepository.UpsertAsync(cipherDetails); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); From 721fda0aaa5a68278ab6f5cc1b2ca80f87bd1a35 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:30:00 +0200 Subject: [PATCH 135/163] [PM-25473] Non-encryption passkeys prevent key rotation (#6359) * use webauthn credentials that have encrypted user key for user key rotation * where condition simplification --- .../WebAuthnLoginKeyRotationValidator.cs | 28 +++++---- src/Core/Auth/Entities/WebAuthnCredential.cs | 17 +++++ .../WebauthnLoginKeyRotationValidatorTests.cs | 62 ++++++++++++------- 3 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs b/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs index 9c7efe0fbe..e92be11cd2 100644 --- a/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs @@ -1,4 +1,5 @@ using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -6,7 +7,13 @@ using Bit.Core.Exceptions; namespace Bit.Api.KeyManagement.Validators; -public class WebAuthnLoginKeyRotationValidator : IRotationValidator, IEnumerable> +/// +/// Validates WebAuthn credentials during key rotation. Only processes credentials that have PRF enabled +/// and have encrypted user, public, and private keys. Ensures all such credentials are included +/// in the rotation request with the required encrypted keys. +/// +public class WebAuthnLoginKeyRotationValidator : IRotationValidator, + IEnumerable> { private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; @@ -15,24 +22,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator> ValidateAsync(User user, IEnumerable keysToRotate) + public async Task> ValidateAsync(User user, + IEnumerable keysToRotate) { var result = new List(); - var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); - if (existing == null) + var validCredentials = (await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)) + .Where(credential => credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled).ToList(); + if (validCredentials.Count == 0) { return result; } - var validCredentials = existing.Where(credential => credential.SupportsPrf); - if (!validCredentials.Any()) + foreach (var webAuthnCredential in validCredentials) { - return result; - } - - foreach (var ea in validCredentials) - { - var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id); + var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == webAuthnCredential.Id); if (keyToRotate == null) { throw new BadRequestException("All existing webauthn prf keys must be included in the rotation."); @@ -42,6 +45,7 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator [MaxLength(20)] public string Type { get; set; } public Guid AaGuid { get; set; } + + /// + /// User key encrypted with this WebAuthn credential's public key (EncryptedPublicKey field). + /// [MaxLength(2000)] public string EncryptedUserKey { get; set; } + + /// + /// Private key encrypted with an external key for secure storage. + /// [MaxLength(2000)] public string EncryptedPrivateKey { get; set; } + + /// + /// Public key encrypted with the user key for key rotation. + /// [MaxLength(2000)] public string EncryptedPublicKey { get; set; } + + /// + /// Indicates whether this credential supports PRF (Pseudo-Random Function) extension. + /// public bool SupportsPrf { get; set; } + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs index 93652735ef..664a46bc9c 100644 --- a/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs @@ -33,8 +33,9 @@ public class WebAuthnLoginKeyRotationValidatorTests { Id = guid, SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) .Returns(new List { data }); @@ -45,8 +46,12 @@ public class WebAuthnLoginKeyRotationValidatorTests } [Theory] - [BitAutoData] - public async Task ValidateAsync_DoesNotSupportPRF_Ignores( + [BitAutoData(false, null, null, null)] + [BitAutoData(true, null, "TestPublicKey", "TestPrivateKey")] + [BitAutoData(true, "TestUserKey", null, "TestPrivateKey")] + [BitAutoData(true, "TestUserKey", "TestPublicKey", null)] + public async Task ValidateAsync_NotEncryptedOrPrfNotSupported_Ignores( + bool supportsPrf, string encryptedUserKey, string encryptedPublicKey, string encryptedPrivateKey, SutProvider sutProvider, User user, IEnumerable webauthnRotateCredentialData) { @@ -58,7 +63,14 @@ public class WebAuthnLoginKeyRotationValidatorTests EncryptedPublicKey = e.EncryptedPublicKey, }).ToList(); - var data = new WebAuthnCredential { Id = guid, EncryptedUserKey = "Test", EncryptedPublicKey = "TestKey" }; + var data = new WebAuthnCredential + { + Id = guid, + SupportsPrf = supportsPrf, + EncryptedUserKey = encryptedUserKey, + EncryptedPublicKey = encryptedPublicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) .Returns(new List { data }); @@ -69,7 +81,7 @@ public class WebAuthnLoginKeyRotationValidatorTests [Theory] [BitAutoData] - public async Task ValidateAsync_WrongWebAuthnKeys_Throws( + public async Task ValidateAsync_WebAuthnKeysNotMatchingExisting_Throws( SutProvider sutProvider, User user, IEnumerable webauthnRotateCredentialData) { @@ -84,10 +96,12 @@ public class WebAuthnLoginKeyRotationValidatorTests { Id = Guid.Parse("00000000-0000-0000-0000-000000000002"), SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new List { data }); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) + .Returns(new List { data }); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate)); @@ -100,20 +114,24 @@ public class WebAuthnLoginKeyRotationValidatorTests IEnumerable webauthnRotateCredentialData) { var guid = Guid.NewGuid(); - var webauthnKeysToRotate = webauthnRotateCredentialData.Select(e => new WebAuthnLoginRotateKeyRequestModel - { - Id = guid, - EncryptedPublicKey = e.EncryptedPublicKey, - }).ToList(); + var webauthnKeysToRotate = webauthnRotateCredentialData.Select(e => + new WebAuthnLoginRotateKeyRequestModel + { + Id = guid, + EncryptedPublicKey = e.EncryptedPublicKey, + EncryptedUserKey = null + }).ToList(); var data = new WebAuthnCredential { Id = guid, SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new List { data }); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) + .Returns(new List { data }); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate)); @@ -131,19 +149,21 @@ public class WebAuthnLoginKeyRotationValidatorTests { Id = guid, EncryptedUserKey = e.EncryptedUserKey, + EncryptedPublicKey = null, }).ToList(); var data = new WebAuthnCredential { Id = guid, SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new List { data }); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) + .Returns(new List { data }); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate)); } - } From bca1d585c597a41525020386ce236b6139f5f9d0 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:13:49 -0400 Subject: [PATCH 136/163] [SM-1489] machine account events (#6187) * Adding new logging for secrets * fixing secrest controller tests * fixing the tests * Server side changes for adding ProjectId to Event table, adding Project event logging to projectsController * Rough draft with TODO's need to work on EventRepository.cs, and ProjectRepository.cs * Undoing changes to make projects soft delete, we want those to be fully deleted still. Adding GetManyTrashedSecretsByIds to secret repo so we can get soft deleted secrets, getSecrets in eventsController takes in orgdId, so that we can check the permission even if the secret was permanently deleted and doesn' thave the org Id set. Adding Secret Perm Deleted, and Restored to event logs * db changes * fixing the way we log events * Trying to undo some manual changes that should have been migrations * adding migration files * fixing test * setting up userid for project controller tests * adding sql * sql * Rename file * Trying to get it to for sure add the column before we try and update sprocs * Adding code to refresh the view to include ProjectId I hope * code improvements * Suggested changes * suggested changes * trying to fix sql issues * fixing swagger issue * Update src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * Suggested changes * Adding event logging for machine accounts * fixing two tests * trying to fix all tests * trying to fix tests * fixing test * Migrations * fix * updating eps * adding migration * Adding missing SQL changes * updating sql * fixing sql * running migration again * fixing sql * adding query to add grantedSErviceAccountId to event table * Suggested improvements * removing more migrations * more removal * removing all migrations to them redo them * redoing migration --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- .../CreateServiceAccountCommand.cs | 12 +- .../Controllers/EventsController.cs | 57 +- .../Models/Response/EventResponseModel.cs | 2 + .../Controllers/AccessPoliciesController.cs | 42 +- .../Controllers/ServiceAccountsController.cs | 20 +- src/Core/AdminConsole/Entities/Event.cs | 3 +- src/Core/AdminConsole/Enums/EventType.cs | 7 + .../AdminConsole/Models/Data/EventMessage.cs | 1 + .../Models/Data/EventTableEntity.cs | 16 +- src/Core/AdminConsole/Models/Data/IEvent.cs | 1 + .../Repositories/IEventRepository.cs | 1 + .../TableStorage/EventRepository.cs | 71 +- .../AdminConsole/Services/IEventService.cs | 4 + .../Services/Implementations/EventService.cs | 130 + .../NoopImplementations/NoopEventService.cs | 16 + .../EventEntityTypeConfiguration.cs | 13 +- ...geByOrganizationIdServiceAccountIdQuery.cs | 2 +- .../EventReadPageByServiceAccountIdQuery.cs | 48 + ...adPageByOrganizationIdServiceAccountId.sql | 2 +- .../Event_ReadPageByServiceAccountId.sql | 45 + .../dbo/Stored Procedures/Event_Create.sql | 9 +- src/Sql/dbo/Tables/Event.sql | 3 +- .../ServiceAccountsControllerTests.cs | 6 +- ...edMachineAccountEventLogsToEventSprocs.sql | 12 + ...edMachineAccountEventLogsToEventSprocs.sql | 189 + ...0250910211149_AddingMAEventLog.Designer.cs | 3278 ++++++++++++++++ .../20250910211149_AddingMAEventLog.cs | 28 + ...0926144434_AddingIndexToEvents.Designer.cs | 3284 ++++++++++++++++ .../20250926144434_AddingIndexToEvents.cs | 27 + .../DatabaseContextModelSnapshot.cs | 10 +- ...0250910211124_AddingMAEventLog.Designer.cs | 3284 ++++++++++++++++ .../20250910211124_AddingMAEventLog.cs | 27 + ...0926144506_AddingIndexToEvents.Designer.cs | 3290 +++++++++++++++++ .../20250926144506_AddingIndexToEvents.cs | 27 + .../DatabaseContextModelSnapshot.cs | 10 +- ...0250910211136_AddingMAEventLog.Designer.cs | 3267 ++++++++++++++++ .../20250910211136_AddingMAEventLog.cs | 27 + ...0926144450_AddingIndexToEvents.Designer.cs | 3273 ++++++++++++++++ .../20250926144450_AddingIndexToEvents.cs | 27 + .../DatabaseContextModelSnapshot.cs | 10 +- 40 files changed, 20553 insertions(+), 28 deletions(-) create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs create mode 100644 src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql create mode 100644 util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql create mode 100644 util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql create mode 100644 util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs create mode 100644 util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs create mode 100644 util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs create mode 100644 util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs create mode 100644 util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs create mode 100644 util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs index 12c7f679bd..b73b358925 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; @@ -13,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand private readonly IAccessPolicyRepository _accessPolicyRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly IEventService _eventService; + private readonly ICurrentContext _currentContext; public CreateServiceAccountCommand( IAccessPolicyRepository accessPolicyRepository, IOrganizationUserRepository organizationUserRepository, - IServiceAccountRepository serviceAccountRepository) + IServiceAccountRepository serviceAccountRepository, + IEventService eventService, + ICurrentContext currentContext) { _accessPolicyRepository = accessPolicyRepository; _organizationUserRepository = organizationUserRepository; _serviceAccountRepository = serviceAccountRepository; + _eventService = eventService; + _currentContext = currentContext; } public async Task CreateAsync(ServiceAccount serviceAccount, Guid userId) @@ -38,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand Write = true, }; await _accessPolicyRepository.CreateManyAsync(new List { accessPolicy }); + await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType); return createdServiceAccount; } } diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/AdminConsole/Controllers/EventsController.cs index 18199ad8f2..f868f0b3b6 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Controllers/EventsController.cs @@ -30,6 +30,8 @@ public class EventsController : Controller private readonly ICurrentContext _currentContext; private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; + public EventsController( IUserService userService, @@ -39,7 +41,8 @@ public class EventsController : Controller IEventRepository eventRepository, ICurrentContext currentContext, ISecretRepository secretRepository, - IProjectRepository projectRepository) + IProjectRepository projectRepository, + IServiceAccountRepository serviceAccountRepository) { _userService = userService; _cipherRepository = cipherRepository; @@ -49,6 +52,7 @@ public class EventsController : Controller _currentContext = currentContext; _secretRepository = secretRepository; _projectRepository = projectRepository; + _serviceAccountRepository = serviceAccountRepository; } [HttpGet("")] @@ -184,6 +188,57 @@ public class EventsController : Controller return new ListResponseModel(responses, result.ContinuationToken); } + [HttpGet("~/organization/{orgId}/service-account/{id}/events")] + public async Task> GetServiceAccounts( + Guid orgId, + Guid id, + [FromQuery] DateTime? start = null, + [FromQuery] DateTime? end = null, + [FromQuery] string continuationToken = null) + { + if (id == Guid.Empty || orgId == Guid.Empty) + { + throw new NotFoundException(); + } + + var serviceAccount = await GetServiceAccount(id, orgId); + var org = _currentContext.GetOrganization(orgId); + + if (org == null || !await _currentContext.AccessEventLogs(org.Id)) + { + throw new NotFoundException(); + } + + var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end); + var result = await _eventRepository.GetManyByOrganizationServiceAccountAsync( + serviceAccount.OrganizationId, + serviceAccount.Id, + fromDate, + toDate, + new PageOptions { ContinuationToken = continuationToken }); + + var responses = result.Data.Select(e => new EventResponseModel(e)); + return new ListResponseModel(responses, result.ContinuationToken); + } + + [ApiExplorerSettings(IgnoreApi = true)] + private async Task GetServiceAccount(Guid serviceAccountId, Guid orgId) + { + var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId); + if (serviceAccount != null) + { + return serviceAccount; + } + + var fallbackServiceAccount = new ServiceAccount + { + Id = serviceAccountId, + OrganizationId = orgId + }; + + return fallbackServiceAccount; + } + [HttpGet("~/organizations/{orgId}/users/{id}/events")] public async Task> GetOrganizationUser(string orgId, string id, [FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null) diff --git a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs b/src/Api/AdminConsole/Models/Response/EventResponseModel.cs index bf02d8b00f..c259bc3bc4 100644 --- a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/EventResponseModel.cs @@ -35,6 +35,7 @@ public class EventResponseModel : ResponseModel SecretId = ev.SecretId; ProjectId = ev.ProjectId; ServiceAccountId = ev.ServiceAccountId; + GrantedServiceAccountId = ev.GrantedServiceAccountId; } public EventType Type { get; set; } @@ -58,4 +59,5 @@ public class EventResponseModel : ResponseModel public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs index cd65a7cdf8..ad5d5e092b 100644 --- a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs +++ b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs @@ -29,6 +29,7 @@ public class AccessPoliciesController : Controller private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IUpdateServiceAccountGrantedPoliciesCommand _updateServiceAccountGrantedPoliciesCommand; private readonly IUserService _userService; + private readonly IEventService _eventService; private readonly IProjectServiceAccountsAccessPoliciesUpdatesQuery _projectServiceAccountsAccessPoliciesUpdatesQuery; private readonly IUpdateProjectServiceAccountsAccessPoliciesCommand @@ -47,7 +48,8 @@ public class AccessPoliciesController : Controller IServiceAccountGrantedPolicyUpdatesQuery serviceAccountGrantedPolicyUpdatesQuery, IProjectServiceAccountsAccessPoliciesUpdatesQuery projectServiceAccountsAccessPoliciesUpdatesQuery, IUpdateServiceAccountGrantedPoliciesCommand updateServiceAccountGrantedPoliciesCommand, - IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand) + IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand, + IEventService eventService) { _authorizationService = authorizationService; _userService = userService; @@ -61,6 +63,7 @@ public class AccessPoliciesController : Controller _serviceAccountGrantedPolicyUpdatesQuery = serviceAccountGrantedPolicyUpdatesQuery; _projectServiceAccountsAccessPoliciesUpdatesQuery = projectServiceAccountsAccessPoliciesUpdatesQuery; _updateProjectServiceAccountsAccessPoliciesCommand = updateProjectServiceAccountsAccessPoliciesCommand; + _eventService = eventService; } [HttpGet("/organizations/{id}/access-policies/people/potential-grantees")] @@ -186,7 +189,9 @@ public class AccessPoliciesController : Controller } var userId = _userService.GetProperUserId(User)!.Value; + var currentPolicies = await _accessPolicyRepository.GetPeoplePoliciesByGrantedServiceAccountIdAsync(peopleAccessPolicies.Id, userId); var results = await _accessPolicyRepository.ReplaceServiceAccountPeopleAsync(peopleAccessPolicies, userId); + await LogAccessPolicyServiceAccountChanges(currentPolicies, results, userId); return new ServiceAccountPeopleAccessPoliciesResponseModel(results, userId); } @@ -336,4 +341,39 @@ public class AccessPoliciesController : Controller userId, accessClient); return new ServiceAccountGrantedPoliciesPermissionDetailsResponseModel(results); } + + public async Task LogAccessPolicyServiceAccountChanges(IEnumerable currentPolicies, IEnumerable updatedPolicies, Guid userId) + { + foreach (var current in currentPolicies.OfType()) + { + if (!updatedPolicies.Any(r => r.Id == current.Id)) + { + await _eventService.LogServiceAccountGroupEventAsync(userId, current, EventType.ServiceAccount_GroupRemoved, _currentContext.IdentityClientType); + } + } + + foreach (var policy in updatedPolicies.OfType()) + { + if (!currentPolicies.Any(e => e.Id == policy.Id)) + { + await _eventService.LogServiceAccountGroupEventAsync(userId, policy, EventType.ServiceAccount_GroupAdded, _currentContext.IdentityClientType); + } + } + + foreach (var current in currentPolicies.OfType()) + { + if (!updatedPolicies.Any(r => r.Id == current.Id)) + { + await _eventService.LogServiceAccountPeopleEventAsync(userId, current, EventType.ServiceAccount_UserRemoved, _currentContext.IdentityClientType); + } + } + + foreach (var policy in updatedPolicies.OfType()) + { + if (!currentPolicies.Any(e => e.Id == policy.Id)) + { + await _eventService.LogServiceAccountPeopleEventAsync(userId, policy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType); + } + } + } } diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 499c496cc9..0afdc3a1bf 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -42,6 +42,8 @@ public class ServiceAccountsController : Controller private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand; private readonly IPricingClient _pricingClient; + private readonly IEventService _eventService; + private readonly IOrganizationUserRepository _organizationUserRepository; public ServiceAccountsController( ICurrentContext currentContext, @@ -58,7 +60,9 @@ public class ServiceAccountsController : Controller IUpdateServiceAccountCommand updateServiceAccountCommand, IDeleteServiceAccountsCommand deleteServiceAccountsCommand, IRevokeAccessTokensCommand revokeAccessTokensCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IEventService eventService, + IOrganizationUserRepository organizationUserRepository) { _currentContext = currentContext; _userService = userService; @@ -75,6 +79,8 @@ public class ServiceAccountsController : Controller _pricingClient = pricingClient; _createAccessTokenCommand = createAccessTokenCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _eventService = eventService; + _organizationUserRepository = organizationUserRepository; } [HttpGet("/organizations/{organizationId}/service-accounts")] @@ -139,8 +145,15 @@ public class ServiceAccountsController : Controller } var userId = _userService.GetProperUserId(User).Value; + var result = - await _createServiceAccountCommand.CreateAsync(createRequest.ToServiceAccount(organizationId), userId); + await _createServiceAccountCommand.CreateAsync(serviceAccount, userId); + + if (result != null) + { + await _eventService.LogServiceAccountEventAsync(userId, [serviceAccount], EventType.ServiceAccount_Created, _currentContext.IdentityClientType); + } + return new ServiceAccountResponseModel(result); } @@ -197,6 +210,9 @@ public class ServiceAccountsController : Controller } await _deleteServiceAccountsCommand.DeleteServiceAccounts(serviceAccountsToDelete); + var userId = _userService.GetProperUserId(User)!.Value; + await _eventService.LogServiceAccountEventAsync(userId, serviceAccountsToDelete, EventType.ServiceAccount_Deleted, _currentContext.IdentityClientType); + var responses = results.Select(r => new BulkDeleteResponseModel(r.ServiceAccount.Id, r.Error)); return new ListResponseModel(responses); } diff --git a/src/Core/AdminConsole/Entities/Event.cs b/src/Core/AdminConsole/Entities/Event.cs index 38d8f07b53..e2868c1915 100644 --- a/src/Core/AdminConsole/Entities/Event.cs +++ b/src/Core/AdminConsole/Entities/Event.cs @@ -34,6 +34,7 @@ public class Event : ITableObject, IEvent SecretId = e.SecretId; ProjectId = e.ProjectId; ServiceAccountId = e.ServiceAccountId; + GrantedServiceAccountId = e.GrantedServiceAccountId; } public Guid Id { get; set; } @@ -59,7 +60,7 @@ public class Event : ITableObject, IEvent public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } - + public Guid? GrantedServiceAccountId { get; set; } public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 81501fd6ec..c80dc2982c 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -109,4 +109,11 @@ public enum EventType : int Project_Created = 2201, Project_Edited = 2202, Project_Deleted = 2203, + + ServiceAccount_UserAdded = 2300, + ServiceAccount_UserRemoved = 2301, + ServiceAccount_GroupAdded = 2302, + ServiceAccount_GroupRemoved = 2303, + ServiceAccount_Created = 2304, + ServiceAccount_Deleted = 2305, } diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/AdminConsole/Models/Data/EventMessage.cs index b708c5bd56..a29d70c203 100644 --- a/src/Core/AdminConsole/Models/Data/EventMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventMessage.cs @@ -39,4 +39,5 @@ public class EventMessage : IEvent public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs index 4ba50aee0d..1c3023f2cf 100644 --- a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs +++ b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs @@ -37,6 +37,7 @@ public class AzureEvent : ITableEntity public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } public EventTableEntity ToEventTableEntity() { @@ -68,6 +69,7 @@ public class AzureEvent : ITableEntity SecretId = SecretId, ServiceAccountId = ServiceAccountId, ProjectId = ProjectId, + GrantedServiceAccountId = GrantedServiceAccountId }; } } @@ -99,6 +101,7 @@ public class EventTableEntity : IEvent SecretId = e.SecretId; ProjectId = e.ProjectId; ServiceAccountId = e.ServiceAccountId; + GrantedServiceAccountId = e.GrantedServiceAccountId; } public string PartitionKey { get; set; } @@ -127,6 +130,7 @@ public class EventTableEntity : IEvent public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } public AzureEvent ToAzureEvent() { @@ -157,7 +161,8 @@ public class EventTableEntity : IEvent DomainName = DomainName, SecretId = SecretId, ProjectId = ProjectId, - ServiceAccountId = ServiceAccountId + ServiceAccountId = ServiceAccountId, + GrantedServiceAccountId = GrantedServiceAccountId }; } @@ -232,6 +237,15 @@ public class EventTableEntity : IEvent }); } + if (e.GrantedServiceAccountId.HasValue) + { + entities.Add(new EventTableEntity(e) + { + PartitionKey = pKey, + RowKey = $"GrantedServiceAccountId={e.GrantedServiceAccountId}__Date={dateKey}__Uniquifier={uniquifier}" + }); + } + return entities; } diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/AdminConsole/Models/Data/IEvent.cs index 750fb2e2eb..3188c905e4 100644 --- a/src/Core/AdminConsole/Models/Data/IEvent.cs +++ b/src/Core/AdminConsole/Models/Data/IEvent.cs @@ -28,4 +28,5 @@ public interface IEvent Guid? SecretId { get; set; } Guid? ProjectId { get; set; } Guid? ServiceAccountId { get; set; } + Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Core/AdminConsole/Repositories/IEventRepository.cs b/src/Core/AdminConsole/Repositories/IEventRepository.cs index 281d6ec8c7..f0c185561b 100644 --- a/src/Core/AdminConsole/Repositories/IEventRepository.cs +++ b/src/Core/AdminConsole/Repositories/IEventRepository.cs @@ -27,6 +27,7 @@ public interface IEventRepository DateTime startDate, DateTime endDate, PageOptions pageOptions); Task> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions); + Task CreateAsync(IEvent e); Task CreateManyAsync(IEnumerable e); Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId, diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs index c9c803b5b2..169b36bf69 100644 --- a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs +++ b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs @@ -77,12 +77,18 @@ public class EventRepository : IEventRepository return await GetManyAsync(partitionKey, $"CipherId={cipher.Id}__Date={{0}}", startDate, endDate, pageOptions); } - public async Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, - Guid serviceAccountId, DateTime startDate, DateTime endDate, PageOptions pageOptions) + public async Task> GetManyByOrganizationServiceAccountAsync( + Guid organizationId, + Guid serviceAccountId, + DateTime startDate, + DateTime endDate, + PageOptions pageOptions) { + return await GetManyServiceAccountAsync( + $"OrganizationId={organizationId}", + serviceAccountId.ToString(), + startDate, endDate, pageOptions); - return await GetManyAsync($"OrganizationId={organizationId}", - $"ServiceAccountId={serviceAccountId}__Date={{0}}", startDate, endDate, pageOptions); } public async Task CreateAsync(IEvent e) @@ -141,6 +147,40 @@ public class EventRepository : IEventRepository } } + public async Task> GetManyServiceAccountAsync( + string partitionKey, + string serviceAccountId, + DateTime startDate, + DateTime endDate, + PageOptions pageOptions) + { + var start = CoreHelpers.DateTimeToTableStorageKey(startDate); + var end = CoreHelpers.DateTimeToTableStorageKey(endDate); + var filter = MakeFilterForServiceAccount(partitionKey, serviceAccountId, startDate, endDate); + + var result = new PagedResult(); + var query = _tableClient.QueryAsync(filter, pageOptions.PageSize); + + await using (var enumerator = query.AsPages(pageOptions.ContinuationToken, + pageOptions.PageSize).GetAsyncEnumerator()) + { + if (await enumerator.MoveNextAsync()) + { + result.ContinuationToken = enumerator.Current.ContinuationToken; + + var events = enumerator.Current.Values + .Select(e => e.ToEventTableEntity()) + .ToList(); + + events = events.OrderByDescending(e => e.Date).ToList(); + + result.Data.AddRange(events); + } + } + + return result; + } + public async Task> GetManyAsync(string partitionKey, string rowKey, DateTime startDate, DateTime endDate, PageOptions pageOptions) { @@ -172,4 +212,27 @@ public class EventRepository : IEventRepository { return $"PartitionKey eq '{partitionKey}' and RowKey le '{rowStart}' and RowKey ge '{rowEnd}'"; } + + private string MakeFilterForServiceAccount( + string partitionKey, + string machineAccountId, + DateTime startDate, + DateTime endDate) + { + var start = CoreHelpers.DateTimeToTableStorageKey(startDate); + var end = CoreHelpers.DateTimeToTableStorageKey(endDate); + + var rowKey1Start = $"ServiceAccountId={machineAccountId}__Date={start}"; + var rowKey1End = $"ServiceAccountId={machineAccountId}__Date={end}"; + + var rowKey2Start = $"GrantedServiceAccountId={machineAccountId}__Date={start}"; + var rowKey2End = $"GrantedServiceAccountId={machineAccountId}__Date={end}"; + + var left = $"PartitionKey eq '{partitionKey}' and RowKey le '{rowKey1Start}' and RowKey ge '{rowKey1End}'"; + var right = $"PartitionKey eq '{partitionKey}' and RowKey le '{rowKey2Start}' and RowKey ge '{rowKey2End}'"; + + return $"({left}) or ({right})"; + } + + } diff --git a/src/Core/AdminConsole/Services/IEventService.cs b/src/Core/AdminConsole/Services/IEventService.cs index 80e8e63d8c..795c06e254 100644 --- a/src/Core/AdminConsole/Services/IEventService.cs +++ b/src/Core/AdminConsole/Services/IEventService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; @@ -37,4 +38,7 @@ public interface IEventService Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable secrets, EventType type, DateTime? date = null); Task LogUserProjectsEventAsync(Guid userId, IEnumerable projects, EventType type, DateTime? date = null); Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable projects, EventType type, DateTime? date = null); + Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null); + Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null); + Task LogServiceAccountEventAsync(Guid userId, List serviceAccount, EventType type, IdentityClientType identityClientType, DateTime? date = null); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/AdminConsole/Services/Implementations/EventService.cs index e0e0e040f1..77d481890e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventService.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -516,6 +517,135 @@ public class EventService : IEventService await _eventWriteService.CreateManyAsync(eventMessages); } + + public async Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + var orgUser = await _organizationUserRepository.GetByIdAsync((Guid)policy.OrganizationUserId); + + if (!CanUseEvents(orgAbilities, orgUser.OrganizationId)) + { + return; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + return; + } + + if (policy.OrganizationUserId != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = orgUser.OrganizationId, + Type = type, + GrantedServiceAccountId = policy.GrantedServiceAccountId, + ServiceAccountId = serviceAccountId, + UserId = policy.OrganizationUserId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + public async Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + if (!CanUseEvents(orgAbilities, policy.Group.OrganizationId)) + { + return; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + return; + } + + if (policy.GroupId != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = policy.Group.OrganizationId, + Type = type, + GrantedServiceAccountId = policy.GrantedServiceAccountId, + ServiceAccountId = serviceAccountId, + GroupId = policy.GroupId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + public async Task LogServiceAccountEventAsync(Guid userId, List serviceAccounts, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var serviceAccount in serviceAccounts) + { + if (!CanUseEvents(orgAbilities, serviceAccount.OrganizationId)) + { + continue; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + continue; + } + + if (serviceAccount != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = serviceAccount.OrganizationId, + Type = type, + GrantedServiceAccountId = serviceAccount.Id, + ServiceAccountId = serviceAccountId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + } + + if (eventMessages.Any()) + { + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + private (Guid? actingUserId, Guid? serviceAccountId) MapIdentityClientType( + Guid userId, IdentityClientType identityClientType) + { + if (identityClientType == IdentityClientType.Organization) + { + return (null, null); + } + + return identityClientType switch + { + IdentityClientType.User => (userId, null), + IdentityClientType.ServiceAccount => (null, userId), + _ => throw new InvalidOperationException("Unknown identity client type.") + }; + } + + private async Task GetProviderIdAsync(Guid? orgId) { if (_currentContext == null || !orgId.HasValue) diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs index e8dd495205..6ecea7d234 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; @@ -139,4 +140,19 @@ public class NoopEventService : IEventService { return Task.FromResult(0); } + + public Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountEventAsync(Guid userId, List serviceAccount, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs index 76e9b2e912..98f10394f4 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs @@ -12,9 +12,16 @@ public class EventEntityTypeConfiguration : IEntityTypeConfiguration .Property(e => e.Id) .ValueGeneratedNever(); - builder - .HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId }) - .IsClustered(false); + builder.HasKey(e => e.Id) + .IsClustered(); + + var index = builder.HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId }) + .IsClustered(false) + .HasDatabaseName("IX_Event_DateOrganizationIdUserId"); + + SqlServerIndexBuilderExtensions.IncludeProperties( + index, + e => new { e.ServiceAccountId, e.GrantedServiceAccountId }); builder.ToTable(nameof(Event)); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs index 01f3a1fe14..72dc8db386 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs @@ -30,7 +30,7 @@ public class EventReadPageByOrganizationIdServiceAccountIdQuery : IQuery (_beforeDate != null || e.Date <= _endDate) && (_beforeDate == null || e.Date < _beforeDate.Value) && e.OrganizationId == _organizationId && - e.ServiceAccountId == _serviceAccountId + (e.ServiceAccountId == _serviceAccountId || e.GrantedServiceAccountId == _serviceAccountId) orderby e.Date descending select e; return q.Skip(0).Take(_pageOptions.PageSize); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs new file mode 100644 index 0000000000..0d1cd6a656 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs @@ -0,0 +1,48 @@ +using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; +using Event = Bit.Infrastructure.EntityFramework.Models.Event; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class EventReadPageByServiceAccountQuery : IQuery +{ + private readonly ServiceAccount _serviceAccount; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly DateTime? _beforeDate; + private readonly PageOptions _pageOptions; + + public EventReadPageByServiceAccountQuery(ServiceAccount serviceAccount, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + _serviceAccount = serviceAccount; + _startDate = startDate; + _endDate = endDate; + _beforeDate = null; + _pageOptions = pageOptions; + } + + public EventReadPageByServiceAccountQuery(ServiceAccount serviceAccount, DateTime startDate, DateTime endDate, DateTime? beforeDate, PageOptions pageOptions) + { + _serviceAccount = serviceAccount; + _startDate = startDate; + _endDate = endDate; + _beforeDate = beforeDate; + _pageOptions = pageOptions; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var q = from e in dbContext.Events + where e.Date >= _startDate && + (_beforeDate == null || e.Date < _beforeDate.Value) && + ( + (_serviceAccount.OrganizationId == Guid.Empty && !e.OrganizationId.HasValue) || + (_serviceAccount.OrganizationId != Guid.Empty && e.OrganizationId == _serviceAccount.OrganizationId) + ) && + e.GrantedServiceAccountId == _serviceAccount.Id + orderby e.Date descending + select e; + + return q.Take(_pageOptions.PageSize); + } +} diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql index 5dc950ffff..831c9f70ee 100644 --- a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql @@ -18,7 +18,7 @@ BEGIN AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) AND [OrganizationId] = @OrganizationId - AND [ServiceAccountId] = @ServiceAccountId + AND ([ServiceAccountId] = @ServiceAccountId OR [GrantedServiceAccountId] = @ServiceAccountId) ORDER BY [Date] DESC OFFSET 0 ROWS FETCH NEXT @PageSize ROWS ONLY diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql new file mode 100644 index 0000000000..c429a4a064 --- /dev/null +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql @@ -0,0 +1,45 @@ +CREATE PROCEDURE [dbo].[Event_ReadPageByServiceAccountId] + @GrantedServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId, + e.GrantedServiceAccountId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [GrantedServiceAccountId] = @GrantedServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END diff --git a/src/Sql/dbo/Stored Procedures/Event_Create.sql b/src/Sql/dbo/Stored Procedures/Event_Create.sql index 89971bd56f..0466bc1a69 100644 --- a/src/Sql/dbo/Stored Procedures/Event_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Event_Create.sql @@ -20,7 +20,8 @@ @DomainName VARCHAR(256), @SecretId UNIQUEIDENTIFIER = null, @ServiceAccountId UNIQUEIDENTIFIER = null, - @ProjectId UNIQUEIDENTIFIER = null + @ProjectId UNIQUEIDENTIFIER = null, + @GrantedServiceAccountId UNIQUEIDENTIFIER = null AS BEGIN SET NOCOUNT ON @@ -48,7 +49,8 @@ BEGIN [DomainName], [SecretId], [ServiceAccountId], - [ProjectId] + [ProjectId], + [GrantedServiceAccountId] ) VALUES ( @@ -73,6 +75,7 @@ BEGIN @DomainName, @SecretId, @ServiceAccountId, - @ProjectId + @ProjectId, + @GrantedServiceAccountId ) END diff --git a/src/Sql/dbo/Tables/Event.sql b/src/Sql/dbo/Tables/Event.sql index 6dfb4392a0..ea0dda5661 100644 --- a/src/Sql/dbo/Tables/Event.sql +++ b/src/Sql/dbo/Tables/Event.sql @@ -21,11 +21,12 @@ [SecretId] UNIQUEIDENTIFIER NULL, [ServiceAccountId] UNIQUEIDENTIFIER NULL, [ProjectId] UNIQUEIDENTIFIER NULL, + [GrantedServiceAccountId] UNIQUEIDENTIFIER NULL, CONSTRAINT [PK_Event] PRIMARY KEY CLUSTERED ([Id] ASC) ); GO CREATE NONCLUSTERED INDEX [IX_Event_DateOrganizationIdUserId] - ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC); + ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC) INCLUDE ([ServiceAccountId], [GrantedServiceAccountId]); diff --git a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index 8147b81240..78224a8bd8 100644 --- a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -361,7 +361,7 @@ public class ServiceAccountsControllerTests [Theory] [BitAutoData] - public async Task BulkDelete_ReturnsAccessDeniedForProjectsWithoutAccess_Success(SutProvider sutProvider, List data) + public async Task BulkDelete_ReturnsAccessDeniedForProjectsWithoutAccess_Success(SutProvider sutProvider, List data, Guid userId) { var ids = data.Select(sa => sa.Id).ToList(); var organizationId = data.First().OrganizationId; @@ -377,6 +377,7 @@ public class ServiceAccountsControllerTests Arg.Any>()).Returns(AuthorizationResult.Failed()); sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(true); sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); var results = await sutProvider.Sut.BulkDeleteAsync(ids); @@ -390,7 +391,7 @@ public class ServiceAccountsControllerTests [Theory] [BitAutoData] - public async Task BulkDelete_Success(SutProvider sutProvider, List data) + public async Task BulkDelete_Success(SutProvider sutProvider, List data, Guid userId) { var ids = data.Select(sa => sa.Id).ToList(); var organizationId = data.First().OrganizationId; @@ -404,6 +405,7 @@ public class ServiceAccountsControllerTests sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(true); sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); var results = await sutProvider.Sut.BulkDeleteAsync(ids); diff --git a/util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql b/util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql new file mode 100644 index 0000000000..a2caeeab77 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql @@ -0,0 +1,12 @@ +IF COL_LENGTH('[dbo].[Event]', 'GrantedServiceAccountId') IS NULL +BEGIN + ALTER TABLE [dbo].[Event] + ADD [GrantedServiceAccountId] UNIQUEIDENTIFIER NULL; +END +GO + +IF OBJECT_ID('[dbo].[EventView]', 'V') IS NOT NULL +BEGIN + EXECUTE sp_refreshview N'[dbo].[EventView]' +END +GO diff --git a/util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql b/util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql new file mode 100644 index 0000000000..10cc771fc5 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql @@ -0,0 +1,189 @@ +-- Create or alter Event_Create procedure +CREATE OR ALTER PROCEDURE [dbo].[Event_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Type INT, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @InstallationId UNIQUEIDENTIFIER, + @ProviderId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @PolicyId UNIQUEIDENTIFIER, + @GroupId UNIQUEIDENTIFIER, + @OrganizationUserId UNIQUEIDENTIFIER, + @ProviderUserId UNIQUEIDENTIFIER, + @ProviderOrganizationId UNIQUEIDENTIFIER = NULL, + @ActingUserId UNIQUEIDENTIFIER, + @DeviceType SMALLINT, + @IpAddress VARCHAR(50), + @Date DATETIME2(7), + @SystemUser TINYINT = NULL, + @DomainName VARCHAR(256), + @SecretId UNIQUEIDENTIFIER = NULL, + @ServiceAccountId UNIQUEIDENTIFIER = NULL, + @ProjectId UNIQUEIDENTIFIER = NULL, + @GrantedServiceAccountId UNIQUEIDENTIFIER = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO [dbo].[Event] + ( + [Id], + [Type], + [UserId], + [OrganizationId], + [InstallationId], + [ProviderId], + [CipherId], + [CollectionId], + [PolicyId], + [GroupId], + [OrganizationUserId], + [ProviderUserId], + [ProviderOrganizationId], + [ActingUserId], + [DeviceType], + [IpAddress], + [Date], + [SystemUser], + [DomainName], + [SecretId], + [ServiceAccountId], + [ProjectId], + [GrantedServiceAccountId] + ) + VALUES + ( + @Id, + @Type, + @UserId, + @OrganizationId, + @InstallationId, + @ProviderId, + @CipherId, + @CollectionId, + @PolicyId, + @GroupId, + @OrganizationUserId, + @ProviderUserId, + @ProviderOrganizationId, + @ActingUserId, + @DeviceType, + @IpAddress, + @Date, + @SystemUser, + @DomainName, + @SecretId, + @ServiceAccountId, + @ProjectId, + @GrantedServiceAccountId + ); +END +GO + +-- Create or alter Event_ReadPageByServiceAccountId procedure +CREATE OR ALTER PROCEDURE [dbo].[Event_ReadPageByServiceAccountId] + @GrantedServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON; + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId, + e.GrantedServiceAccountId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [GrantedServiceAccountId] = @GrantedServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Event_ReadPageByOrganizationIdServiceAccountId] + @OrganizationId UNIQUEIDENTIFIER, + @ServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EventView] + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [OrganizationId] = @OrganizationId + AND ([ServiceAccountId] = @ServiceAccountId OR [GrantedServiceAccountId] = @ServiceAccountId) + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END +GO + +IF EXISTS(SELECT 1 FROM sys.indexes WHERE name = 'IX_Event_DateOrganizationIdUserId') +BEGIN + -- Check if neither ServiceAccountId nor GrantedServiceAccountId are included columns + IF NOT EXISTS ( + SELECT 1 + FROM + sys.indexes i + INNER JOIN + sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN + sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id + WHERE + i.object_id = OBJECT_ID('[dbo].[Event]') + AND i.name = 'IX_Event_DateOrganizationIdUserId' + AND c.name IN ('ServiceAccountId', 'GrantedServiceAccountId') + AND ic.is_included_column = 1 + ) + BEGIN + CREATE NONCLUSTERED INDEX [IX_Event_DateOrganizationIdUserId] + ON [dbo].[Event] + ( [Date] DESC, + [OrganizationId] ASC, + [ActingUserId] ASC, + [CipherId] ASC + ) + INCLUDE ([ServiceAccountId], [GrantedServiceAccountId]) + WITH (DROP_EXISTING = ON) + END +END +GO \ No newline at end of file diff --git a/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs new file mode 100644 index 0000000000..7bfe9caace --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs @@ -0,0 +1,3278 @@ +// +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("20250910211149_AddingMAEventLog")] + partial class AddingMAEventLog + { + /// + 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("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + 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/20250910211149_AddingMAEventLog.cs b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs new file mode 100644 index 0000000000..ad268f0dc8 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddingMAEventLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GrantedServiceAccountId", + table: "Event", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GrantedServiceAccountId", + table: "Event"); + } +} diff --git a/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs new file mode 100644 index 0000000000..6dcc834ece --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs @@ -0,0 +1,3284 @@ +// +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("20250926144434_AddingIndexToEvents")] + partial class AddingIndexToEvents + { + /// + 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("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + 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") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + 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("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.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/20250926144434_AddingIndexToEvents.cs b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs new file mode 100644 index 0000000000..162f7d2954 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddingIndexToEvents : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_Date_OrganizationId_ActingUserId_CipherId", + table: "Event", + newName: "IX_Event_DateOrganizationIdUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_DateOrganizationIdUserId", + table: "Event", + newName: "IX_Event_Date_OrganizationId_ActingUserId_CipherId"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index beab31cebf..dce61f805c 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1282,6 +1282,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("DomainName") .HasColumnType("longtext"); + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + b.Property("GroupId") .HasColumnType("char(36)"); @@ -1328,10 +1331,13 @@ namespace Bit.MySqlMigrations.Migrations b.Property("UserId") .HasColumnType("char(36)"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") - .HasAnnotation("SqlServer:Clustered", false); + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); b.ToTable("Event", (string)null); }); diff --git a/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs new file mode 100644 index 0000000000..5a85eb6144 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs @@ -0,0 +1,3284 @@ +// +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("20250910211124_AddingMAEventLog")] + partial class AddingMAEventLog + { + /// + 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("GrantedServiceAccountId") + .HasColumnType("uuid"); + + 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/20250910211124_AddingMAEventLog.cs b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs new file mode 100644 index 0000000000..5dbaef5950 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddingMAEventLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GrantedServiceAccountId", + table: "Event", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GrantedServiceAccountId", + table: "Event"); + } +} diff --git a/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs new file mode 100644 index 0000000000..4f7cd718ea --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs @@ -0,0 +1,3290 @@ +// +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("20250926144506_AddingIndexToEvents")] + partial class AddingIndexToEvents + { + /// + 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("GrantedServiceAccountId") + .HasColumnType("uuid"); + + 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") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + 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("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.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/20250926144506_AddingIndexToEvents.cs b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs new file mode 100644 index 0000000000..876d2b9347 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddingIndexToEvents : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_Date_OrganizationId_ActingUserId_CipherId", + table: "Event", + newName: "IX_Event_DateOrganizationIdUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_DateOrganizationIdUserId", + table: "Event", + newName: "IX_Event_Date_OrganizationId_ActingUserId_CipherId"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 3d66e2c035..c6ed007410 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1287,6 +1287,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("DomainName") .HasColumnType("text"); + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + b.Property("GroupId") .HasColumnType("uuid"); @@ -1333,10 +1336,13 @@ namespace Bit.PostgresMigrations.Migrations b.Property("UserId") .HasColumnType("uuid"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") - .HasAnnotation("SqlServer:Clustered", false); + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); b.ToTable("Event", (string)null); }); diff --git a/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs new file mode 100644 index 0000000000..43091d1136 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs @@ -0,0 +1,3267 @@ +// +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("20250910211136_AddingMAEventLog")] + partial class AddingMAEventLog + { + /// + 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("GrantedServiceAccountId") + .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/20250910211136_AddingMAEventLog.cs b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs new file mode 100644 index 0000000000..0022934414 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddingMAEventLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GrantedServiceAccountId", + table: "Event", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GrantedServiceAccountId", + table: "Event"); + } +} diff --git a/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs new file mode 100644 index 0000000000..6473c5a3f1 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs @@ -0,0 +1,3273 @@ +// +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("20250926144450_AddingIndexToEvents")] + partial class AddingIndexToEvents + { + /// + 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("GrantedServiceAccountId") + .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") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + 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("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.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/20250926144450_AddingIndexToEvents.cs b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs new file mode 100644 index 0000000000..65de0c9ef1 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddingIndexToEvents : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_Date_OrganizationId_ActingUserId_CipherId", + table: "Event", + newName: "IX_Event_DateOrganizationIdUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_DateOrganizationIdUserId", + table: "Event", + newName: "IX_Event_Date_OrganizationId_ActingUserId_CipherId"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index d091cb4830..494431b932 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1271,6 +1271,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("DomainName") .HasColumnType("TEXT"); + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + b.Property("GroupId") .HasColumnType("TEXT"); @@ -1317,10 +1320,13 @@ namespace Bit.SqliteMigrations.Migrations b.Property("UserId") .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") - .HasAnnotation("SqlServer:Clustered", false); + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); b.ToTable("Event", (string)null); }); From 7cefca330b856e04f1e9efc16ff95b3824f476df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:28:19 +0100 Subject: [PATCH 137/163] [PM-26050] Migrate all DefaultUserCollection when claimed user is deleted (#6366) * feat: migrate DefaultUserCollection to SharedCollection during user deletion - Implemented migration of DefaultUserCollection to SharedCollection in EF UserRepository before deleting organization users. - Updated stored procedures User_DeleteById and User_DeleteByIds to include migration logic. - Added new migration script for updating stored procedures. * Add unit test for user deletion and DefaultUserCollection migration - Implemented a new test to verify the migration of DefaultUserCollection to SharedCollection during user deletion in UserRepository. - The test ensures that the user is deleted and the associated collection is updated correctly. * Refactor user deletion process in UserRepository - Moved migrating DefaultUserCollection to SharedCollection to happen before the deletion of user-related entities. - Updated the deletion logic to use ExecuteDeleteAsync for improved performance and clarity. - Ensured that all related entities are removed in a single transaction to maintain data integrity. * Add unit test for DeleteManyAsync in UserRepository - Implemented a new test to verify the deletion of multiple users and the migration of their DefaultUserCollections to SharedCollections. - Ensured that both users are deleted and their associated collections are updated correctly in a single transaction. * Refactor UserRepositoryTests to use test user creation methods and streamline collection creation * Ensure changes are saved after deleting users in bulk * Refactor UserRepository to simplify migration queries and remove unnecessary loops for better performance * Refactor UserRepository to encapsulate DefaultUserCollection migration logic in a separate method * Refactor UserRepository to optimize deletion queries by using joins instead of subqueries for improved performance * Refactor UserRepositoryTest DeleteManyAsync_Works to ensure GroupUser and CollectionUser deletion --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .../Repositories/UserRepository.cs | 62 +++- .../dbo/Stored Procedures/User_DeleteById.sql | 10 + .../Stored Procedures/User_DeleteByIds.sql | 10 + .../Auth/Repositories/UserRepositoryTests.cs | 163 ++++++--- ..._MigrateDefaultCollectionsOnUserDelete.sql | 325 ++++++++++++++++++ 5 files changed, 512 insertions(+), 58 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index bd70e27e78..809704edb7 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -283,6 +283,9 @@ public class UserRepository : Repository, IUserR var transaction = await dbContext.Database.BeginTransactionAsync(); + MigrateDefaultUserCollectionsToShared(dbContext, [user.Id]); + await dbContext.SaveChangesAsync(); + dbContext.WebAuthnCredentials.RemoveRange(dbContext.WebAuthnCredentials.Where(w => w.UserId == user.Id)); dbContext.Ciphers.RemoveRange(dbContext.Ciphers.Where(c => c.UserId == user.Id)); dbContext.Folders.RemoveRange(dbContext.Folders.Where(f => f.UserId == user.Id)); @@ -314,8 +317,8 @@ public class UserRepository : Repository, IUserR var mappedUser = Mapper.Map(user); dbContext.Users.Remove(mappedUser); - await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); } } @@ -329,21 +332,30 @@ public class UserRepository : Repository, IUserR var targetIds = users.Select(u => u.Id).ToList(); + MigrateDefaultUserCollectionsToShared(dbContext, targetIds); + await dbContext.SaveChangesAsync(); + await dbContext.WebAuthnCredentials.Where(wa => targetIds.Contains(wa.UserId)).ExecuteDeleteAsync(); await dbContext.Ciphers.Where(c => targetIds.Contains(c.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.Folders.Where(f => targetIds.Contains(f.UserId)).ExecuteDeleteAsync(); await dbContext.AuthRequests.Where(a => targetIds.Contains(a.UserId)).ExecuteDeleteAsync(); await dbContext.Devices.Where(d => targetIds.Contains(d.UserId)).ExecuteDeleteAsync(); - var collectionUsers = from cu in dbContext.CollectionUsers - join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id - where targetIds.Contains(ou.UserId ?? default) - select cu; - dbContext.CollectionUsers.RemoveRange(collectionUsers); - var groupUsers = from gu in dbContext.GroupUsers - join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id - where targetIds.Contains(ou.UserId ?? default) - select gu; - dbContext.GroupUsers.RemoveRange(groupUsers); + await dbContext.CollectionUsers + .Join(dbContext.OrganizationUsers, + cu => cu.OrganizationUserId, + ou => ou.Id, + (cu, ou) => new { CollectionUser = cu, OrganizationUser = ou }) + .Where((joined) => targetIds.Contains(joined.OrganizationUser.UserId ?? default)) + .Select(joined => joined.CollectionUser) + .ExecuteDeleteAsync(); + await dbContext.GroupUsers + .Join(dbContext.OrganizationUsers, + gu => gu.OrganizationUserId, + ou => ou.Id, + (gu, ou) => new { GroupUser = gu, OrganizationUser = ou }) + .Where(joined => targetIds.Contains(joined.OrganizationUser.UserId ?? default)) + .Select(joined => joined.GroupUser) + .ExecuteDeleteAsync(); await dbContext.UserProjectAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.UserServiceAccountAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.OrganizationUsers.Where(ou => targetIds.Contains(ou.UserId ?? default)).ExecuteDeleteAsync(); @@ -354,15 +366,29 @@ public class UserRepository : Repository, IUserR await dbContext.NotificationStatuses.Where(ns => targetIds.Contains(ns.UserId)).ExecuteDeleteAsync(); await dbContext.Notifications.Where(n => targetIds.Contains(n.UserId ?? default)).ExecuteDeleteAsync(); - foreach (var u in users) - { - var mappedUser = Mapper.Map(u); - dbContext.Users.Remove(mappedUser); - } + await dbContext.Users.Where(u => targetIds.Contains(u.Id)).ExecuteDeleteAsync(); - - await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + } + + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) + { + var defaultCollections = (from c in dbContext.Collections + join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId + join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id + join u in dbContext.Users on ou.UserId equals u.Id + where userIds.Contains(ou.UserId!.Value) + && c.Type == Core.Enums.CollectionType.DefaultUserCollection + select new { Collection = c, UserEmail = u.Email }) + .ToList(); + + foreach (var item in defaultCollections) + { + item.Collection.Type = Core.Enums.CollectionType.SharedCollection; + item.Collection.DefaultUserCollectionEmail = item.Collection.DefaultUserCollectionEmail ?? item.UserEmail; + item.Collection.RevisionDate = DateTime.UtcNow; } } } diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index 0608982e37..6377166e17 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -52,6 +52,16 @@ BEGIN WHERE [UserId] = @Id + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] = @Id + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + -- Delete collection users DELETE CU diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql index 97ab955f83..cdf3dd7d3a 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql @@ -66,6 +66,16 @@ BEGIN WHERE [UserId] IN (SELECT * FROM @ParsedIds) + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] IN (SELECT * FROM @ParsedIds) + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + -- Delete collection users DELETE CU diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs index 0bf0909a0a..dd84df07be 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs @@ -1,7 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -25,53 +27,40 @@ public class UserRepositoryTests Assert.Null(deletedUser); } - [DatabaseTheory, DatabaseData] - public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository) + [Theory, DatabaseData] + public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, IGroupRepository groupRepository) { - var user1 = await userRepository.CreateAsync(new User - { - Name = "Test User 1", - Email = $"test+{Guid.NewGuid()}@email.com", - ApiKey = "TEST", - SecurityStamp = "stamp", - }); + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var user3 = await userRepository.CreateTestUserAsync(); - var user2 = await userRepository.CreateAsync(new User - { - Name = "Test User 2", - Email = $"test+{Guid.NewGuid()}@email.com", - ApiKey = "TEST", - SecurityStamp = "stamp", - }); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); + var orgUser3 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user3); - var user3 = await userRepository.CreateAsync(new User - { - Name = "Test User 3", - Email = $"test+{Guid.NewGuid()}@email.com", - ApiKey = "TEST", - SecurityStamp = "stamp", - }); + var group1 = await groupRepository.CreateTestGroupAsync(organization, "test-group-1"); + var group2 = await groupRepository.CreateTestGroupAsync(organization, "test-group-2"); + await groupRepository.UpdateUsersAsync(group1.Id, [orgUser1.Id]); + await groupRepository.UpdateUsersAsync(group2.Id, [orgUser3.Id]); - var organization = await organizationRepository.CreateAsync(new Organization - { - Name = "Test Org", - BillingEmail = user3.Email, // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser + var collection1 = new Collection { OrganizationId = organization.Id, - UserId = user1.Id, - Status = OrganizationUserStatusType.Confirmed, - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser + Name = "test-collection-1" + }; + var collection2 = new Collection { OrganizationId = organization.Id, - UserId = user3.Id, - Status = OrganizationUserStatusType.Confirmed, - }); + Name = "test-collection-2" + }; + + await collectionRepository.CreateAsync( + collection1, + groups: [new CollectionAccessSelection { Id = group1.Id, HidePasswords = false, ReadOnly = false, Manage = true }], + users: [new CollectionAccessSelection { Id = orgUser1.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + await collectionRepository.CreateAsync(collection2, + groups: [new CollectionAccessSelection { Id = group2.Id, HidePasswords = false, ReadOnly = false, Manage = true }], + users: [new CollectionAccessSelection { Id = orgUser3.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); await userRepository.DeleteManyAsync(new List { @@ -94,6 +83,100 @@ public class UserRepositoryTests Assert.Null(orgUser1Deleted); Assert.NotNull(notDeletedOrgUsers); Assert.True(notDeletedOrgUsers.Count > 0); + + var collection1WithUsers = await collectionRepository.GetByIdWithPermissionsAsync(collection1.Id, null, true); + var collection2WithUsers = await collectionRepository.GetByIdWithPermissionsAsync(collection2.Id, null, true); + Assert.Empty(collection1WithUsers.Users); // Collection1 should have no users (orgUser1 was deleted) + Assert.Single(collection2WithUsers.Users); // Collection2 should still have orgUser3 (not deleted) + Assert.Single(collection2WithUsers.Users); + Assert.Equal(orgUser3.Id, collection2WithUsers.Users.First().Id); + + var group1Users = await groupRepository.GetManyUserIdsByIdAsync(group1.Id); + var group2Users = await groupRepository.GetManyUserIdsByIdAsync(group2.Id); + + Assert.Empty(group1Users); // Group1 should have no users (orgUser1 was deleted) + Assert.Single(group2Users); // Group2 should still have orgUser3 (not deleted) + Assert.Equal(orgUser3.Id, group2Users.First()); } + [Theory, DatabaseData] + public async Task DeleteAsync_WhenUserHasDefaultUserCollections_MigratesToSharedCollection( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + + var defaultUserCollection = new Collection + { + Name = "Test Collection", + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }; + await collectionRepository.CreateAsync( + defaultUserCollection, + groups: null, + users: [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + + await userRepository.DeleteAsync(user); + + var deletedUser = await userRepository.GetByIdAsync(user.Id); + Assert.Null(deletedUser); + + var updatedCollection = await collectionRepository.GetByIdAsync(defaultUserCollection.Id); + Assert.NotNull(updatedCollection); + Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type); + Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail); + } + + [Theory, DatabaseData] + public async Task DeleteManyAsync_WhenUsersHaveDefaultUserCollections_MigratesToSharedCollection( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); + var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user2); + + var defaultUserCollection1 = new Collection + { + Name = "Test Collection 1", + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }; + + var defaultUserCollection2 = new Collection + { + Name = "Test Collection 2", + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }; + + await collectionRepository.CreateAsync(defaultUserCollection1, groups: null, users: [new CollectionAccessSelection { Id = orgUser1.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + await collectionRepository.CreateAsync(defaultUserCollection2, groups: null, users: [new CollectionAccessSelection { Id = orgUser2.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + + await userRepository.DeleteManyAsync([user1, user2]); + + var deletedUser1 = await userRepository.GetByIdAsync(user1.Id); + var deletedUser2 = await userRepository.GetByIdAsync(user2.Id); + Assert.Null(deletedUser1); + Assert.Null(deletedUser2); + + var updatedCollection1 = await collectionRepository.GetByIdAsync(defaultUserCollection1.Id); + Assert.NotNull(updatedCollection1); + Assert.Equal(CollectionType.SharedCollection, updatedCollection1.Type); + Assert.Equal(user1.Email, updatedCollection1.DefaultUserCollectionEmail); + + var updatedCollection2 = await collectionRepository.GetByIdAsync(defaultUserCollection2.Id); + Assert.NotNull(updatedCollection2); + Assert.Equal(CollectionType.SharedCollection, updatedCollection2.Type); + Assert.Equal(user2.Email, updatedCollection2.DefaultUserCollectionEmail); + } } diff --git a/util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql b/util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql new file mode 100644 index 0000000000..517ef732a0 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql @@ -0,0 +1,325 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_DeleteById] + @Id UNIQUEIDENTIFIER +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] = @Id + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] = @Id + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] = @Id + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] = @Id + + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] = @Id + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] = @Id + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] = @Id + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] = @Id + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] = @Id + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] = @Id + OR + [GranteeId] = @Id + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] = @Id + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] = @Id + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] = @Id + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] = @Id + + COMMIT TRANSACTION User_DeleteById +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_DeleteByIds] + @Ids NVARCHAR(MAX) +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] IN (SELECT * FROM @ParsedIds) + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] IN (SELECT * FROM @ParsedIds) + OR + [GranteeId] IN (SELECT * FROM @ParsedIds) + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] IN (SELECT * FROM @ParsedIds) + + COMMIT TRANSACTION User_DeleteById +END +GO From 61265c7533711591d74a3b0c88364ed9e067df47 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:26:39 -0500 Subject: [PATCH 138/163] [PM-25463] Work towards complete usage of Payments domain (#6363) * Use payment domain * Run dotnet format and remove unused code * Fix swagger * Stephon's feedback * Run dotnet format --- .../AdminConsole/Services/ProviderService.cs | 20 +- .../Services/ProviderBillingService.cs | 246 ++-- .../Services/ProviderServiceTests.cs | 83 +- .../Services/ProviderBillingServiceTests.cs | 342 ++--- .../Controllers/ProvidersController.cs | 17 +- .../Providers/ProviderSetupRequestModel.cs | 8 +- .../OrganizationBillingController.cs | 79 +- src/Api/Billing/Controllers/TaxController.cs | 78 +- .../VNext/AccountBillingVNextController.cs | 3 +- .../OrganizationBillingVNextController.cs | 18 + ...ganizationSubscriptionPlanChangeRequest.cs | 31 + ...OrganizationSubscriptionPurchaseRequest.cs | 84 ++ .../OrganizationSubscriptionUpdateRequest.cs | 48 + .../Requests/Payment/BillingAddressRequest.cs | 3 +- .../Requests/Payment/BitPayCreditRequest.cs | 3 +- .../Payment/CheckoutBillingAddressRequest.cs | 3 +- .../Payment/MinimalBillingAddressRequest.cs | 3 +- .../MinimalTokenizedPaymentMethodRequest.cs | 14 +- .../Payment/TokenizedPaymentMethodRequest.cs | 24 +- .../PremiumCloudHostedSubscriptionRequest.cs | 3 +- ...axAmountForOrganizationTrialRequestBody.cs | 27 - .../RestartSubscriptionRequest.cs | 16 + ...izationSubscriptionPlanChangeTaxRequest.cs | 19 + ...anizationSubscriptionPurchaseTaxRequest.cs | 19 + ...rganizationSubscriptionUpdateTaxRequest.cs | 11 + ...ewPremiumSubscriptionPurchaseTaxRequest.cs | 17 + .../AdminConsole/Services/IProviderService.cs | 5 +- .../NoopProviderService.cs | 4 +- .../Billing/Commands/BillingCommandResult.cs | 29 +- src/Core/Billing/Enums/PlanCadenceType.cs | 7 + .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Commands/PreviewOrganizationTaxCommand.cs | 383 +++++ .../OrganizationSubscriptionPlanChange.cs | 23 + .../OrganizationSubscriptionPurchase.cs | 39 + .../Models/OrganizationSubscriptionUpdate.cs | 19 + .../Commands/PreviewPremiumTaxCommand.cs | 65 + .../Implementations/ProviderMigrator.cs | 2 +- .../Services/IProviderBillingService.cs | 12 +- .../Commands/RestartSubscriptionCommand.cs | 92 ++ .../Tax/Commands/PreviewTaxAmountCommand.cs | 136 -- src/Core/Constants.cs | 1 - .../Services/Implementations/StripeAdapter.cs | 3 + .../PreviewOrganizationTaxCommandTests.cs | 1262 +++++++++++++++++ .../Commands/PreviewPremiumTaxCommandTests.cs | 292 ++++ .../RestartSubscriptionCommandTests.cs | 198 +++ .../Commands/PreviewTaxAmountCommandTests.cs | 541 ------- 46 files changed, 2988 insertions(+), 1350 deletions(-) create mode 100644 src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs delete mode 100644 src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs create mode 100644 src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs create mode 100644 src/Core/Billing/Enums/PlanCadenceType.cs create mode 100644 src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs create mode 100644 src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs create mode 100644 src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs delete mode 100644 src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs create mode 100644 test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs create mode 100644 test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs delete mode 100644 test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index aa19ad5382..aaf0050b63 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -12,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -90,7 +90,7 @@ public class ProviderService : IProviderService _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } - public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) + public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) { var owner = await _userService.GetUserByIdAsync(ownerUserId); if (owner == null) @@ -115,21 +115,7 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } - if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) - { - throw new BadRequestException("Both address and postal code are required to set up your provider."); - } - - if (tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - throw new BadRequestException("A payment method is required to set up your provider."); - } - - var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); provider.GatewaySubscriptionId = subscription.Id; 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 398674c7b6..c9851eb403 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -14,6 +14,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; @@ -21,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -38,6 +37,9 @@ using Subscription = Stripe.Subscription; namespace Bit.Commercial.Core.Billing.Providers.Services; +using static Constants; +using static StripeConstants; + public class ProviderBillingService( IBraintreeGateway braintreeGateway, IEventService eventService, @@ -51,8 +53,7 @@ public class ProviderBillingService( IProviderUserRepository providerUserRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - ITaxService taxService) + ISubscriberService subscriberService) : IProviderBillingService { public async Task AddExistingOrganization( @@ -61,10 +62,7 @@ public class ProviderBillingService( string key) { await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, - new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = false - }); + new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); var subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, @@ -83,7 +81,7 @@ public class ProviderBillingService( var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; - if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft) + if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft) { await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = true }); @@ -184,16 +182,8 @@ public class ProviderBillingService( { Items = [ - new SubscriptionItemOptions - { - Price = newPriceId, - Quantity = oldSubscriptionItem!.Quantity - }, - new SubscriptionItemOptions - { - Id = oldSubscriptionItem.Id, - Deleted = true - } + new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity }, + new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true } ] }; @@ -202,7 +192,8 @@ public class ProviderBillingService( // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // 1. Retrieve PlanType and PlanName for ProviderPlan // 2. Assign PlanType & PlanName to Organization - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); + var providerOrganizations = + await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); @@ -213,6 +204,7 @@ public class ProviderBillingService( { throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); } + organization.PlanType = newPlanType; organization.Plan = newPlan.Name; await organizationRepository.ReplaceAsync(organization); @@ -228,15 +220,15 @@ public class ProviderBillingService( if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId)); + logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, + nameof(organization.GatewayCustomerId)); return; } - var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions - { - Expand = ["tax", "tax_ids"] - }); + var providerCustomer = + await subscriberService.GetCustomerOrThrow(provider, + new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); @@ -269,23 +261,18 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - }, - TaxIdData = providerTaxId == null ? null : - [ - new CustomerTaxIdDataOptions - { - Type = providerTaxId.Type, - Value = providerTaxId.Value - } - ] + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxIdData = providerTaxId == null + ? null + : + [ + new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value } + ] }; - if (providerCustomer.Address is not { Country: Constants.CountryAbbreviations.UnitedStates }) + if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates }) { - customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + customerCreateOptions.TaxExempt = TaxExempt.Reverse; } var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); @@ -347,9 +334,9 @@ public class ProviderBillingService( .Where(pair => pair.subscription is { Status: - StripeConstants.SubscriptionStatus.Active or - StripeConstants.SubscriptionStatus.Trialing or - StripeConstants.SubscriptionStatus.PastDue + SubscriptionStatus.Active or + SubscriptionStatus.Trialing or + SubscriptionStatus.PastDue }).ToList(); if (active.Count == 0) @@ -474,37 +461,27 @@ public class ProviderBillingService( // Below the limit to above the limit (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || // Above the limit to further above the limit - (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); + (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && + newlyAssignedSeatTotal > currentlyAssignedSeatTotal); } public async Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) { - ArgumentNullException.ThrowIfNull(tokenizedPaymentSource); - - if (taxInfo is not - { - BillingAddressCountry: not null and not "", - BillingAddressPostalCode: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id); - throw new BillingException(); - } - var options = new CustomerCreateOptions { Address = new AddressOptions { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode, - Line1 = taxInfo.BillingAddressLine1, - Line2 = taxInfo.BillingAddressLine2, - City = taxInfo.BillingAddressCity, - State = taxInfo.BillingAddressState + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State }, + Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null, Description = provider.DisplayBusinessName(), Email = provider.BillingEmail, InvoiceSettings = new CustomerInvoiceSettingsOptions @@ -520,93 +497,61 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - } + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None }; - if (taxInfo.BillingAddressCountry is not Constants.CountryAbbreviations.UnitedStates) + if (billingAddress.TaxId != null) { - options.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - if (taxIdType == null) - { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - options.TaxIdData = [ - new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } + new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value } ]; - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF) { options.TaxIdData.Add(new CustomerTaxIdDataOptions { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxInfo.TaxIdNumber}" + Type = TaxIdType.EUVAT, + Value = $"ES{billingAddress.TaxId.Value}" }); } } - if (!string.IsNullOrEmpty(provider.DiscountId)) - { - options.Coupon = provider.DiscountId; - } - var braintreeCustomerId = ""; - if (tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) with invalid payment method", provider.Id); - throw new BillingException(); - } - - var (type, token) = tokenizedPaymentSource; - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (type) + switch (paymentMethod.Type) { - case PaymentMethodType.BankAccount: + case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + PaymentMethod = paymentMethod.Token + })) .FirstOrDefault(); if (setupIntent == null) { - logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); + logger.LogError( + "Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", + provider.Id); throw new BillingException(); } await setupIntentCache.Set(provider.Id, setupIntent.Id); break; } - case PaymentMethodType.Card: + case TokenizablePaymentMethodType.Card: { - options.PaymentMethod = token; - options.InvoiceSettings.DefaultPaymentMethod = token; + options.PaymentMethod = paymentMethod.Token; + options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; break; } - case PaymentMethodType.PayPal: + case TokenizablePaymentMethodType.PayPal: { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token); options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } @@ -616,8 +561,7 @@ public class ProviderBillingService( { return await stripeAdapter.CustomerCreateAsync(options); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == - StripeConstants.ErrorCodes.TaxIdInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid) { await Revert(); throw new BadRequestException( @@ -632,9 +576,9 @@ public class ProviderBillingService( async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (tokenizedPaymentSource.Type) + switch (paymentMethod.Type) { - case PaymentMethodType.BankAccount: + case TokenizablePaymentMethodType.BankAccount: { var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); await stripeAdapter.SetupIntentCancel(setupIntentId, @@ -642,7 +586,7 @@ public class ProviderBillingService( await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; } - case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): { await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); break; @@ -661,9 +605,10 @@ public class ProviderBillingService( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - if (providerPlans == null || providerPlans.Count == 0) + if (providerPlans.Count == 0) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id); + logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", + provider.Id); throw new BillingException(); } @@ -676,7 +621,9 @@ public class ProviderBillingService( if (!providerPlan.IsConfigured()) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name); + logger.LogError( + "Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", + provider.Id, plan.Name); throw new BillingException(); } @@ -692,16 +639,14 @@ public class ProviderBillingService( var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) - ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }) + ? await stripeAdapter.SetupIntentGet(setupIntentId, + new SetupIntentGetOptions { Expand = ["payment_method"] }) : null; var usePaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) || - (customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true) || - (setupIntent?.IsUnverifiedBankAccount() == true); + customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true || + setupIntent?.IsUnverifiedBankAccount() == true; int? trialPeriodDays = provider.Type switch { @@ -712,30 +657,28 @@ public class ProviderBillingService( var subscriptionCreateOptions = new SubscriptionCreateOptions { - CollectionMethod = usePaymentMethod ? - StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, + CollectionMethod = + usePaymentMethod + ? CollectionMethod.ChargeAutomatically + : CollectionMethod.SendInvoice, Customer = customer.Id, DaysUntilDue = usePaymentMethod ? null : 30, Items = subscriptionItemOptionsList, - Metadata = new Dictionary - { - { "providerId", provider.Id.ToString() } - }, + Metadata = new Dictionary { { "providerId", provider.Id.ToString() } }, OffSession = true, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - TrialPeriodDays = trialPeriodDays + ProrationBehavior = ProrationBehavior.CreateProrations, + TrialPeriodDays = trialPeriodDays, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }; - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - try { var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); if (subscription is { - Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing + Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing }) { return subscription; @@ -749,9 +692,11 @@ public class ProviderBillingService( throw new BillingException(); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + ErrorCodes.CustomerTaxLocationInvalid) { - throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid."); + throw new BadRequestException( + "Your location wasn't recognized. Please ensure your country and postal code are valid."); } } @@ -765,7 +710,7 @@ public class ProviderBillingService( subscriberService.UpdateTaxInformation(provider, taxInformation)); await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, - new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); + new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically }); } public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) @@ -865,13 +810,9 @@ public class ProviderBillingService( await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { - Items = [ - new SubscriptionItemOptions - { - Id = item.Id, - Price = priceId, - Quantity = newlySubscribedSeats - } + Items = + [ + new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats } ] }); @@ -894,7 +835,8 @@ public class ProviderBillingService( var plan = await pricingClient.GetPlanOrThrow(planType); return providerOrganizations - .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) + .Where(providerOrganization => providerOrganization.Plan == plan.Name && + providerOrganization.Status == OrganizationStatusType.Managed) .Sum(providerOrganization => providerOrganization.Seats ?? 0); } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index f2ba2fab8f..e61cf5f97e 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -9,7 +9,7 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -41,7 +41,7 @@ public class ProviderServiceTests public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null)); Assert.Contains("Invalid owner.", exception.Message); } @@ -53,83 +53,12 @@ public class ProviderServiceTests userService.GetUserByIdAsync(user.Id).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null)); Assert.Contains("Invalid token.", exception.Message); } [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - taxInfo.BillingAddressCountry = null; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("A payment method is required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource, + public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress, [ProviderUser] ProviderUser providerUser, SutProvider sutProvider) { @@ -149,7 +78,7 @@ public class ProviderServiceTests var providerBillingService = sutProvider.GetDependency(); var customer = new Customer { Id = "customer_id" }; - providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer); + providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer); var subscription = new Subscription { Id = "subscription_id" }; providerBillingService.SetupSubscription(provider).Returns(subscription); @@ -158,7 +87,7 @@ public class ProviderServiceTests var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource); + await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress); await sutProvider.GetDependency().Received().UpsertAsync(Arg.Is( p => 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 54c0b82aa9..18c71364e6 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 @@ -1,5 +1,4 @@ using System.Globalization; -using System.Net; using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities; @@ -10,18 +9,16 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; 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; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -895,118 +892,53 @@ public class ProviderBillingServiceTests #region SetupCustomer [Theory, BitAutoData] - public async Task SetupCustomer_MissingCountry_ContactSupport( + public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + BillingAddress billingAddress) { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_MissingPostalCode_ContactSupport( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - - [Theory, BitAutoData] - public async Task SetupCustomer_NullPaymentSource_ThrowsArgumentNullException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo) - { - await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, null)); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; - - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await Assert.ThrowsAsync(() => + sutProvider.Sut.SetupCustomer(provider, null, billingAddress)); } [Theory, BitAutoData] public async Task SetupCustomer_WithBankAccount_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); - - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); @@ -1020,45 +952,37 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); - - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Customer.Received(1).DeleteAsync("braintree_customer_id"); } @@ -1067,17 +991,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithBankAccount_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1087,31 +1005,30 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); @@ -1122,17 +1039,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1142,30 +1053,29 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1174,17 +1084,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1194,28 +1098,26 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1224,17 +1126,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_ReverseCharge_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "FR"; // Non-US country to trigger reverse charge + billingAddress.TaxId = new TaxID("fr_siren", "123456789"); var stripeAdapter = sutProvider.GetDependency(); @@ -1244,55 +1140,51 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value && o.TaxExempt == StripeConstants.TaxExempt.Reverse)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } [Theory, BitAutoData] - public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( + public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + BillingAddress billingAddress) { provider.Name = "MSP"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id"); - taxInfo.BillingAddressCountry = "AD"; + var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns((string)null); + stripeAdapter.CustomerCreateAsync(Arg.Any()) + .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } }); var actual = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); - Assert.IsType(actual); - Assert.Equal("billingTaxIdTypeInferenceError", actual.Message); + Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message); } #endregion diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index a1815fd3bf..aa87bf9c74 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; @@ -93,22 +92,12 @@ public class ProvidersController : Controller var userId = _userService.GetProperUserId(User).Value; - var taxInfo = new TaxInfo - { - BillingAddressCountry = model.TaxInfo.Country, - BillingAddressPostalCode = model.TaxInfo.PostalCode, - TaxIdNumber = model.TaxInfo.TaxId, - BillingAddressLine1 = model.TaxInfo.Line1, - BillingAddressLine2 = model.TaxInfo.Line2, - BillingAddressCity = model.TaxInfo.City, - BillingAddressState = model.TaxInfo.State - }; - - var tokenizedPaymentSource = model.PaymentSource?.ToDomain(); + var paymentMethod = model.PaymentMethod.ToDomain(); + var billingAddress = model.BillingAddress.ToDomain(); var response = await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key, - taxInfo, tokenizedPaymentSource); + paymentMethod, billingAddress); return new ProviderResponseModel(response); } diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs index 1f50c384a3..41cebe8b9b 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs @@ -3,8 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Api.Billing.Models.Requests; -using Bit.Api.Models.Request; +using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Utilities; @@ -28,8 +27,9 @@ public class ProviderSetupRequestModel [Required] public string Key { get; set; } [Required] - public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; } - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } + public MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public BillingAddressRequest BillingAddress { get; set; } public virtual Provider ToProvider(Provider provider) { diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 21b17bff67..1d6bf51661 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,16 +1,8 @@ -#nullable enable -using System.Diagnostics; -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Api.Billing.Models.Requests; +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; -using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -28,10 +20,8 @@ public class OrganizationBillingController( IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, - IPricingClient pricingClient, ISubscriberService subscriberService, - IPaymentHistoryService paymentHistoryService, - IUserService userService) : BaseBillingController + IPaymentHistoryService paymentHistoryService) : BaseBillingController { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -264,71 +254,6 @@ public class OrganizationBillingController( return TypedResults.Ok(); } - [HttpPost("restart-subscription")] - public async Task RestartSubscriptionAsync([FromRoute] Guid organizationId, - [FromBody] OrganizationCreateRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization == null) - { - return Error.NotFound(); - } - var existingPlan = organization.PlanType; - var organizationSignup = model.ToOrganizationSignup(user); - var sale = OrganizationSale.From(organization, organizationSignup); - var plan = await pricingClient.GetPlanOrThrow(model.PlanType); - sale.Organization.PlanType = plan.Type; - sale.Organization.Plan = plan.Name; - sale.SubscriptionSetup.SkipTrial = true; - if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null) - { - sale.Organization.UseTotp = plan.HasTotp; - sale.Organization.UseGroups = plan.HasGroups; - sale.Organization.UseDirectory = plan.HasDirectory; - sale.Organization.SelfHost = plan.HasSelfHost; - sale.Organization.UsersGetPremium = plan.UsersGetPremium; - sale.Organization.UseEvents = plan.HasEvents; - sale.Organization.Use2fa = plan.Has2fa; - sale.Organization.UseApi = plan.HasApi; - sale.Organization.UsePolicies = plan.HasPolicies; - sale.Organization.UseSso = plan.HasSso; - sale.Organization.UseResetPassword = plan.HasResetPassword; - sale.Organization.UseKeyConnector = plan.HasKeyConnector ? organization.UseKeyConnector : false; - sale.Organization.UseScim = plan.HasScim; - sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; - sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; - sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections; - } - - if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) - { - return Error.BadRequest("A payment method is required to restart the subscription."); - } - var org = await organizationRepository.GetByIdAsync(organizationId); - Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); - var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); - var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.Finalize(sale); - var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); - if (updatedOrg != null) - { - await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); - } - - return TypedResults.Ok(); - } - [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs index d2c1c36726..4ead414589 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -1,33 +1,73 @@ -using Bit.Api.Billing.Models.Requests; -using Bit.Core.Billing.Tax.Commands; +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Premium.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("tax")] +[Route("billing/tax")] public class TaxController( - IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController + IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, + IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController { - [HttpPost("preview-amount/organization-trial")] - public async Task PreviewTaxAmountForOrganizationTrialAsync( - [FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody) + [HttpPost("organizations/subscriptions/purchase")] + public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( + [FromBody] PreviewOrganizationSubscriptionPurchaseTaxRequest request) { - var parameters = new OrganizationTrialParameters + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new { - PlanType = requestBody.PlanType, - ProductType = requestBody.ProductType, - TaxInformation = new OrganizationTrialParameters.TaxInformationDTO - { - Country = requestBody.TaxInformation.Country, - PostalCode = requestBody.TaxInformation.PostalCode, - TaxId = requestBody.TaxInformation.TaxId - } - }; + pair.Tax, + pair.Total + })); + } - var result = await previewTaxAmountCommand.Run(parameters); + [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionPlanChangeTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionPlanChangeTaxRequest request) + { + var (planChange, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } - return Handle(result); + [HttpPut("organizations/{organizationId:guid}/subscription/update")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionUpdateTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionUpdateTaxRequest request) + { + var update = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, update); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } + + [HttpPost("premium/subscriptions/purchase")] + public async Task PreviewPremiumSubscriptionPurchaseTaxAsync( + [FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request) + { + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index a996290507..97f2003d29 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Core; diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index ee98031dbc..2f825f2cb9 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -2,11 +2,14 @@ using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Subscriptions; using Bit.Api.Billing.Models.Requirements; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,6 +27,7 @@ public class OrganizationBillingVNextController( IGetCreditQuery getCreditQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, + IRestartSubscriptionCommand restartSubscriptionCommand, IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { @@ -95,6 +99,20 @@ public class OrganizationBillingVNextController( return Handle(result); } + [Authorize] + [HttpPost("subscription/restart")] + [InjectOrganization] + public async Task RestartSubscriptionAsync( + [BindNever] Organization organization, + [FromBody] RestartSubscriptionRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, null) + .AndThenAsync(_ => updateBillingAddressCommand.Run(organization, billingAddress)) + .AndThenAsync(_ => restartSubscriptionCommand.Run(organization)); + return Handle(result); + } + [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs new file mode 100644 index 0000000000..a3856bf173 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPlanChangeRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + public OrganizationSubscriptionPlanChange ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier == ProductTierType.Families && Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly billing cadence is not available for the Families plan."); + } + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs new file mode 100644 index 0000000000..c678b1966c --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPurchaseRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + [Required] + public required PasswordManagerPurchaseSelections PasswordManager { get; set; } + + public SecretsManagerPurchaseSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionPurchase ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage, + Sponsored = PasswordManager.Sponsored + }, + SecretsManager = SecretsManager != null ? new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, + Standalone = SecretsManager.Standalone + } : null + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier != ProductTierType.Families) + { + yield break; + } + + if (Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly cadence is not available on the Families plan."); + } + + if (SecretsManager != null) + { + yield return new ValidationResult("Secrets Manager is not available on the Families plan."); + } + } + + public record PasswordManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int AdditionalStorage { get; set; } + + public bool Sponsored { get; set; } = false; + } + + public record SecretsManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Secrets Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int AdditionalServiceAccounts { get; set; } + + public bool Standalone { get; set; } = false; + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs new file mode 100644 index 0000000000..ad5c3bd609 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionUpdateRequest +{ + public PasswordManagerUpdateSelections? PasswordManager { get; set; } + public SecretsManagerUpdateSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => new() + { + PasswordManager = + PasswordManager != null + ? new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage + } + : null, + SecretsManager = + SecretsManager != null + ? new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts + } + : null + }; + + public record PasswordManagerUpdateSelections + { + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int? AdditionalStorage { get; set; } + } + + public record SecretsManagerUpdateSelections + { + [Range(0, 100000, ErrorMessage = "Secrets Manager seats must be between 0 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int? AdditionalServiceAccounts { get; set; } + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs index 5c3c47f585..0426a51f10 100644 --- a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs index bb6e7498d7..ec1405c566 100644 --- a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs index 54116e897d..ccf2b30b50 100644 --- a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs index b4d28017d5..29c10e6631 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs index 3b50d2bf63..b0e415c262 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Attributes; using Bit.Core.Billing.Payment.Models; @@ -14,12 +13,9 @@ public class MinimalTokenizedPaymentMethodRequest [Required] public required string Token { get; set; } - public TokenizedPaymentMethod ToDomain() + public TokenizedPaymentMethod ToDomain() => new() { - return new TokenizedPaymentMethod - { - Type = TokenizablePaymentMethodTypeExtensions.From(Type), - Token = Token - }; - } + 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 f540957a1a..2a54313421 100644 --- a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -1,31 +1,15 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Api.Billing.Attributes; -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; -public class TokenizedPaymentMethodRequest +public class TokenizedPaymentMethodRequest : MinimalTokenizedPaymentMethodRequest { - [Required] - [PaymentMethodTypeValidation] - public required string Type { get; set; } - - [Required] - public required string Token { get; set; } - public MinimalBillingAddressRequest? BillingAddress { get; set; } - public (TokenizedPaymentMethod, BillingAddress?) ToDomain() + public new (TokenizedPaymentMethod, BillingAddress?) ToDomain() { - var paymentMethod = new TokenizedPaymentMethod - { - Type = TokenizablePaymentMethodTypeExtensions.From(Type), - Token = Token - }; - + var paymentMethod = base.ToDomain(); var billingAddress = BillingAddress?.ToDomain(); - return (paymentMethod, billingAddress); } } diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index b958057f5b..03f20ec9c1 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs deleted file mode 100644 index a3fda0fd6c..0000000000 --- a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs +++ /dev/null @@ -1,27 +0,0 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class PreviewTaxAmountForOrganizationTrialRequestBody -{ - [Required] - public PlanType PlanType { get; set; } - - [Required] - public ProductType ProductType { get; set; } - - [Required] public TaxInformationDTO TaxInformation { get; set; } = null!; - - public class TaxInformationDTO - { - [Required] - public string Country { get; set; } = null!; - - [Required] - public string PostalCode { get; set; } = null!; - - public string? TaxId { get; set; } - } -} diff --git a/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs new file mode 100644 index 0000000000..ac66270427 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Subscriptions; + +public class RestartSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (TokenizedPaymentMethod, BillingAddress) ToDomain() + => (PaymentMethod.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs new file mode 100644 index 0000000000..9233a53c85 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPlanChangeTaxRequest +{ + [Required] + public required OrganizationSubscriptionPlanChangeRequest Plan { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPlanChange, BillingAddress) ToDomain() => + (Plan.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..dcc5911f3d --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPurchaseTaxRequest +{ + [Required] + public required OrganizationSubscriptionPurchaseRequest Purchase { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPurchase, BillingAddress) ToDomain() => + (Purchase.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs new file mode 100644 index 0000000000..ae96214ae3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -0,0 +1,11 @@ +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public class PreviewOrganizationSubscriptionUpdateTaxRequest +{ + public required OrganizationSubscriptionUpdateRequest Update { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => Update.ToDomain(); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..76b8a5a444 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewPremiumSubscriptionPurchaseTaxRequest +{ + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB.")] + public short AdditionalStorage { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain()); +} diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index 66c49d90c6..2b954346ae 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -11,8 +11,7 @@ namespace Bit.Core.AdminConsole.Services; public interface IProviderService { - Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource = null); + Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress); Task UpdateAsync(Provider provider, bool updateBilling = false); Task> InviteUserAsync(ProviderUserInvite invite); diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index 2bf4a54a87..3782b30e3f 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -11,7 +11,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations; public class NoopProviderService : IProviderService { - public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException(); + public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) => throw new NotImplementedException(); public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException(); diff --git a/src/Core/Billing/Commands/BillingCommandResult.cs b/src/Core/Billing/Commands/BillingCommandResult.cs index 3238ab4107..db260e7038 100644 --- a/src/Core/Billing/Commands/BillingCommandResult.cs +++ b/src/Core/Billing/Commands/BillingCommandResult.cs @@ -1,5 +1,4 @@ -#nullable enable -using OneOf; +using OneOf; namespace Bit.Core.Billing.Commands; @@ -20,18 +19,38 @@ public record Unhandled(Exception? Exception = null, string Response = "Somethin /// /// /// The successful result type of the operation. -public class BillingCommandResult : OneOfBase +public class BillingCommandResult(OneOf input) + : OneOfBase(input) { - private BillingCommandResult(OneOf input) : base(input) { } - public static implicit operator BillingCommandResult(T output) => new(output); public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); public static implicit operator BillingCommandResult(Conflict conflict) => new(conflict); public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); + public BillingCommandResult Map(Func f) + => Match( + value => new BillingCommandResult(f(value)), + badRequest => new BillingCommandResult(badRequest), + conflict => new BillingCommandResult(conflict), + unhandled => new BillingCommandResult(unhandled)); + public Task TapAsync(Func f) => Match( f, _ => Task.CompletedTask, _ => Task.CompletedTask, _ => Task.CompletedTask); } + +public static class BillingCommandResultExtensions +{ + public static async Task> AndThenAsync( + this Task> task, Func>> binder) + { + var result = await task; + return await result.Match( + binder, + badRequest => Task.FromResult(new BillingCommandResult(badRequest)), + conflict => Task.FromResult(new BillingCommandResult(conflict)), + unhandled => Task.FromResult(new BillingCommandResult(unhandled))); + } +} diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs new file mode 100644 index 0000000000..9e6fa69832 --- /dev/null +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Enums; + +public enum PlanCadenceType +{ + Annually, + Monthly +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index b4e37f0151..7aec422a4b 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -9,7 +9,7 @@ using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; @@ -28,11 +28,12 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); - services.AddTransient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) @@ -46,5 +47,6 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs new file mode 100644 index 0000000000..041e9bdbad --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -0,0 +1,383 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Organizations.Commands; + +using static Core.Constants; +using static StripeConstants; + +public interface IPreviewOrganizationTaxCommand +{ + Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update); +} + +public class PreviewOrganizationTaxCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), IPreviewOrganizationTaxCommand +{ + public Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var plan = await pricingClient.GetPlanOrThrow(purchase.PlanType); + + var options = GetBaseOptions(billingAddress, purchase.Tier != ProductTierType.Families); + + var items = new List(); + + switch (purchase) + { + case { PasswordManager.Sponsored: true }: + var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise); + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = sponsoredPlan.StripePlanId, + Quantity = 1 + }); + break; + + case { SecretsManager.Standalone: true }: + items.AddRange([ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }, + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + } + ]); + options.Coupon = CouponIDs.SecretsManagerStandalone; + break; + + default: + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }); + + if (purchase.PasswordManager.AdditionalStorage > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeStoragePlanId, + Quantity = purchase.PasswordManager.AdditionalStorage + }); + } + + if (purchase.SecretsManager is { Seats: > 0 }) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + }); + + if (purchase.SecretsManager.AdditionalServiceAccounts > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = purchase.SecretsManager.AdditionalServiceAccounts + }); + } + } + + break; + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization.PlanType.GetProductTier() == ProductTierType.Free) + { + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var items = new List + { + new () + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = 2 + } + }; + + if (organization.UseSecretsManager && planChange.Tier != ProductTierType.Families) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = 2 + }); + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + else + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var subscriptionItemsByPriceId = + subscription.Items.ToDictionary(subscriptionItem => subscriptionItem.Price.Id); + + var items = new List(); + + var passwordManagerSeats = subscriptionItemsByPriceId[ + currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId]; + + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerSeats.Quantity + }); + + var hasStorage = + subscriptionItemsByPriceId.TryGetValue(newPlan.PasswordManager.StripeStoragePlanId, + out var storage); + + if (hasStorage && storage != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storage.Quantity + }); + } + + var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue( + newPlan.SecretsManager.StripeSeatPlanId, + out var secretsManagerSeats); + + if (hasSecretsManagerSeats && secretsManagerSeats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerSeats.Quantity + }); + + var hasServiceAccounts = + subscriptionItemsByPriceId.TryGetValue(newPlan.SecretsManager.StripeServiceAccountPlanId, + out var serviceAccounts); + + if (hasServiceAccounts && serviceAccounts != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccounts.Quantity + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); + + var options = GetBaseOptions(subscription.Customer, + organization.GetProductUsageType() == ProductUsageType.Business); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var items = new List(); + + if (update.PasswordManager?.Seats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId, + Quantity = update.PasswordManager.Seats + }); + } + + if (update.PasswordManager?.AdditionalStorage is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.PasswordManager.StripeStoragePlanId, + Quantity = update.PasswordManager.AdditionalStorage + }); + } + + if (update.SecretsManager?.Seats is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeSeatPlanId, + Quantity = update.SecretsManager.Seats + }); + + if (update.SecretsManager.AdditionalServiceAccounts is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = update.SecretsManager.AdditionalServiceAccounts + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); + + private static InvoiceCreatePreviewOptions GetBaseOptions( + OneOf addressChoice, + bool businessUse) + { + var country = addressChoice.Match( + customer => customer.Address.Country, + billingAddress => billingAddress.Country + ); + + var postalCode = addressChoice.Match( + customer => customer.Address.PostalCode, + billingAddress => billingAddress.PostalCode); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions { Country = country, PostalCode = postalCode }, + TaxExempt = businessUse && country != CountryAbbreviations.UnitedStates + ? TaxExempt.Reverse + : TaxExempt.None + } + }; + + var taxId = addressChoice.Match( + customer => + { + var taxId = customer.TaxIds?.FirstOrDefault(); + return taxId != null ? new TaxID(taxId.Type, taxId.Value) : null; + }, + billingAddress => billingAddress.TaxId); + + if (taxId == null) + { + return options; + } + + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxId.Code, Value = taxId.Value } + ]; + + if (taxId.Code == TaxIdType.SpanishNIF) + { + options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions + { + Type = TaxIdType.EUVAT, + Value = $"ES{taxId.Value}" + }); + } + + return options; + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs new file mode 100644 index 0000000000..7781f91960 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPlanChange +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot change an Organization subscription to a tier that isn't Families, Teams or Enterprise.") + }; +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs new file mode 100644 index 0000000000..6691d69848 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -0,0 +1,39 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPurchase +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + public required PasswordManagerSelections PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot purchase an Organization subscription that isn't Families, Teams or Enterprise.") + }; + + public record PasswordManagerSelections + { + public int Seats { get; init; } + public int AdditionalStorage { get; init; } + public bool Sponsored { get; init; } + } + + public record SecretsManagerSelections + { + public int Seats { get; init; } + public int AdditionalServiceAccounts { get; init; } + public bool Standalone { get; init; } + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..810f292c81 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionUpdate +{ + public PasswordManagerSelections? PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public record PasswordManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalStorage { get; init; } + } + + public record SecretsManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalServiceAccounts { get; init; } + } +} diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs new file mode 100644 index 0000000000..a0b4fcabc2 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -0,0 +1,65 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +using static StripeConstants; + +public interface IPreviewPremiumTaxCommand +{ + Task> Run( + int additionalStorage, + BillingAddress billingAddress); +} + +public class PreviewPremiumTaxCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand +{ + public Task> Run( + int additionalStorage, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + Currency = "usd", + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } + ] + } + }; + + if (additionalStorage > 0) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = Prices.StoragePlanPersonal, + Quantity = additionalStorage + }); + } + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); +} diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 07a057d40c..e155b427f1 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -258,7 +258,7 @@ public class ProviderMigrator( // Create dummy payment source for legacy migration - this migrator is deprecated and will be removed var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token"); - var customer = await providerBillingService.SetupCustomer(provider, taxInfo, dummyPaymentSource); + var customer = await providerBillingService.SetupCustomer(provider, null, null); await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index 173249f79f..57d68db038 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -5,10 +5,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Models.Business; using Stripe; namespace Bit.Core.Billing.Providers.Services; @@ -79,16 +79,16 @@ public interface IProviderBillingService int seatAdjustment); /// - /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided . + /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided and . /// /// The to create a Stripe customer for. - /// The to use for calculating the customer's automatic tax. - /// The (ex. Credit Card) to attach to the customer. + /// The (e.g., Credit Card, Bank Account, or PayPal) to attach to the customer. + /// The containing the customer's billing information including address and tax ID details. /// The newly created for the . Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource); + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress); /// /// For use during the provider setup process, this method starts a Stripe for the given . diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs new file mode 100644 index 0000000000..351c75ace0 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -0,0 +1,92 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IRestartSubscriptionCommand +{ + Task> Run( + ISubscriber subscriber); +} + +public class RestartSubscriptionCommand( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IRestartSubscriptionCommand +{ + public async Task> Run( + ISubscriber subscriber) + { + var existingSubscription = await subscriberService.GetSubscription(subscriber); + + if (existingSubscription is not { Status: SubscriptionStatus.Canceled }) + { + return new BadRequest("Cannot restart a subscription that is not canceled."); + } + + var options = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + CollectionMethod = CollectionMethod.ChargeAutomatically, + Customer = existingSubscription.CustomerId, + Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions + { + Price = subscriptionItem.Price.Id, + Quantity = subscriptionItem.Quantity + }).ToList(), + Metadata = existingSubscription.Metadata, + OffSession = true, + TrialPeriodDays = 0 + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(options); + await EnableAsync(subscriber, subscription); + return new None(); + } + + private async Task EnableAsync(ISubscriber subscriber, Subscription subscription) + { + switch (subscriber) + { + case Organization organization: + { + organization.GatewaySubscriptionId = subscription.Id; + organization.Enabled = true; + organization.ExpirationDate = subscription.CurrentPeriodEnd; + organization.RevisionDate = DateTime.UtcNow; + await organizationRepository.ReplaceAsync(organization); + break; + } + case Provider provider: + { + provider.GatewaySubscriptionId = subscription.Id; + provider.Enabled = true; + provider.RevisionDate = DateTime.UtcNow; + await providerRepository.ReplaceAsync(provider); + break; + } + case User user: + { + user.GatewaySubscriptionId = subscription.Id; + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.RevisionDate = DateTime.UtcNow; + await userRepository.ReplaceAsync(user); + break; + } + } + } +} diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs deleted file mode 100644 index 94d3724d73..0000000000 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Tax.Commands; - -public interface IPreviewTaxAmountCommand -{ - Task> Run(OrganizationTrialParameters parameters); -} - -public class PreviewTaxAmountCommand( - ILogger logger, - IPricingClient pricingClient, - IStripeAdapter stripeAdapter, - ITaxService taxService) : BaseBillingCommand(logger), IPreviewTaxAmountCommand -{ - protected override Conflict DefaultConflict - => new("We had a problem calculating your tax obligation. Please contact support for assistance."); - - public Task> Run(OrganizationTrialParameters parameters) - => HandleAsync(async () => - { - var (planType, productType, taxInformation) = parameters; - - var plan = await pricingClient.GetPlanOrThrow(planType); - - var options = new InvoiceCreatePreviewOptions - { - Currency = "usd", - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - Country = taxInformation.Country, - PostalCode = taxInformation.PostalCode - } - }, - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.HasNonSeatBasedPasswordManagerPlan() - ? plan.PasswordManager.StripePlanId - : plan.PasswordManager.StripeSeatPlanId, - Quantity = 1 - } - ] - } - }; - - if (productType == ProductType.SecretsManager) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.SecretsManager.StripeSeatPlanId, - Quantity = 1 - }); - - options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; - } - - if (!string.IsNullOrEmpty(taxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInformation.Country, - taxInformation.TaxId); - - if (string.IsNullOrEmpty(taxIdType)) - { - return new BadRequest( - "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance."); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = taxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - if (parameters.PlanType.IsBusinessProductTierType() && - parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) - { - options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); - return Convert.ToDecimal(invoice.Tax) / 100; - }); -} - -#region Command Parameters - -public record OrganizationTrialParameters -{ - public required PlanType PlanType { get; set; } - public required ProductType ProductType { get; set; } - public required TaxInformationDTO TaxInformation { get; set; } - - public void Deconstruct( - out PlanType planType, - out ProductType productType, - out TaxInformationDTO taxInformation) - { - planType = PlanType; - productType = ProductType; - taxInformation = TaxInformation; - } - - public record TaxInformationDTO - { - public required string Country { get; set; } - public required string PostalCode { get; set; } - public string? TaxId { get; set; } - } -} - -#endregion diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d26e0f67fa..bcc9b0b40d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -169,7 +169,6 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - 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"; diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 03d1776e90..4863baf73e 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Tax; namespace Bit.Core.Services; @@ -23,6 +24,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.TestHelpers.TestClockService _testClockService; private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; private readonly Stripe.Tax.RegistrationService _taxRegistrationService; + private readonly CalculationService _calculationService; public StripeAdapter() { @@ -41,6 +43,7 @@ public class StripeAdapter : IStripeAdapter _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); _taxRegistrationService = new Stripe.Tax.RegistrationService(); + _calculationService = new CalculationService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs new file mode 100644 index 0000000000..8e3cd5a0fa --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -0,0 +1,1262 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; + +namespace Bit.Core.Test.Billing.Organizations.Commands; + +public class PreviewOrganizationTaxCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewOrganizationTaxCommand _command; + + public PreviewOrganizationTaxCommandTests() + { + _command = new PreviewOrganizationTaxCommand(_logger, _pricingClient, _stripeAdapter); + } + + #region Subscription Purchase + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SponsoredPasswordManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = true + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify the correct Stripe API call for sponsored subscription + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 3, + AdditionalServiceAccounts = 0, + Standalone = true + } + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 750, + Total = 8250 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(7.50m, tax); + Assert.Equal(82.50m, total); + + // Verify the correct Stripe API call for standalone secrets manager + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) && + options.Coupon == CouponIDs.SecretsManagerStandalone)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandardPurchaseWithStorage_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = 5, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = 3, + Standalone = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA", + TaxId = new TaxID("gb_vat", "123456789") + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 1200, + Total = 12200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(122.00m, total); + + // Verify the correct Stripe API call for comprehensive purchase with storage and service accounts + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "gb_vat" && + options.CustomerDetails.TaxIds[0].Value == "123456789" && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 3) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_FamiliesTier_NoSecretsManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "90210" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify the correct Stripe API call for Families tier (non-seat-based plan) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 6 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_BusinessUseNonUSCountry_UsesTaxExemptReverse() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 3, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 0, + Total = 2700 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(0.00m, tax); + Assert.Equal(27.00m, total); + + // Verify the correct Stripe API call for business use in non-US country (tax exempt reverse) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 3 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SpanishNIFTaxId_AddsEUVATTaxId() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 15, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "ES", + PostalCode = "28001", + TaxId = new TaxID(TaxIdType.SpanishNIF, "12345678Z") + }; + + var plan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 2100, + Total = 12100 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(21.00m, tax); + Assert.Equal(121.00m, total); + + // Verify the correct Stripe API call for Spanish NIF that adds both Spanish NIF and EU VAT tax IDs + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "ES" && + options.CustomerDetails.Address.PostalCode == "28001" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 2 && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.SpanishNIF && t.Value == "12345678Z") && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.EUVAT && t.Value == "ES12345678Z") && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 15 && + options.Coupon == null)); + } + + #endregion + + #region Subscription Plan Change + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationToTeams_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 120, + Total = 1320 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(1.20m, tax); + Assert.Equal(13.20m, total); + + // Verify the correct Stripe API call for free organization upgrade to Teams + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 2 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationToFamilies_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 400, + Total = 4400 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.00m, tax); + Assert.Equal(44.00m, total); + + // Verify the correct Stripe API call for free organization upgrade to Families (no SM for Families) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 2 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationWithSecretsManagerToEnterprise_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA" + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + // Verify the correct Stripe API call for free organization with SM to Enterprise + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 2) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 2) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_ExistingSubscriptionUpgrade_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var currentPlan = new TeamsPlan(false); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // Mock existing subscription with items - using NEW plan IDs since command looks for new plan prices + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-monthly" }, Quantity = 8 }, + new() { Price = new Price { Id = "storage-gb-annually" }, Quantity = 3 }, + new() { Price = new Price { Id = "secrets-manager-enterprise-seat-annually" }, Quantity = 5 }, + new() { Price = new Price { Id = "secrets-manager-service-account-2024-annually" }, Quantity = 10 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1500, + Total = 16500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify the correct Stripe API call for existing subscription upgrade + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 3) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 10) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_ExistingSubscriptionWithDiscount_PreservesCoupon() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "90210" + }; + + var currentPlan = new TeamsPlan(true); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // Mock existing subscription with discount + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-annually" }, Quantity = 5 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer + { + Discount = new Discount + { + Coupon = new Coupon { Id = "EXISTING_DISCOUNT_50" } + } + } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify the correct Stripe API call preserves existing discount + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Coupon == "EXISTING_DISCOUNT_50")); + } + + [Fact] + public async Task Run_OrganizationPlanChange_OrganizationWithoutGatewayIds_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = null, + GatewaySubscriptionId = null + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Organization does not have a subscription.", badRequest.Response); + + // Verify no Stripe API calls were made + await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region Subscription Update + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_PasswordManagerSeatsOnly_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = null + } + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "US", PostalCode = "12345" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify the correct Stripe API call for PM seats only + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 10 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_PasswordManagerWithStorage_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 15, + AdditionalStorage = 5 + } + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "CA", PostalCode = "K1A 0A6" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1200, + Total = 13200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(132.00m, total); + + // Verify the correct Stripe API call for PM seats + storage + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 15) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_SecretsManagerOnly_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = null + } + }; + + var plan = new TeamsPlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "DE", PostalCode = "10115" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + // Verify the correct Stripe API call for SM seats only + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "secrets-manager-teams-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 8 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_SecretsManagerWithServiceAccounts_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 12, + AdditionalServiceAccounts = 20 + } + }; + + var plan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "GB", PostalCode = "SW1A 1AA" }, + Discount = null, + TaxIds = new StripeList + { + Data = new List + { + new() { Type = "gb_vat", Value = "GB123456789" } + } + } + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1500, + Total = 16500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify the correct Stripe API call for SM seats + service accounts with tax ID + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "gb_vat" && + options.CustomerDetails.TaxIds[0].Value == "GB123456789" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 12) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 20) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_ComprehensiveUpdate_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 25, + AdditionalStorage = 10 + }, + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 15, + AdditionalServiceAccounts = 30 + } + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "ES", PostalCode = "28001" }, + Discount = new Discount + { + Coupon = new Coupon { Id = "ENTERPRISE_DISCOUNT_20" } + }, + TaxIds = new StripeList + { + Data = new List + { + new() { Type = TaxIdType.SpanishNIF, Value = "12345678Z" } + } + } + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 2500, + Total = 27500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(25.00m, tax); + Assert.Equal(275.00m, total); + + // Verify the correct Stripe API call for comprehensive update with discount and Spanish tax ID + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "ES" && + options.CustomerDetails.Address.PostalCode == "28001" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 2 && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.SpanishNIF && t.Value == "12345678Z") && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.EUVAT && t.Value == "ES12345678Z") && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 25) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 15) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 30) && + options.Coupon == "ENTERPRISE_DISCOUNT_20")); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_FamiliesTierPersonalUsage_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 2 + } + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "AU", PostalCode = "2000" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify the correct Stripe API call for Families tier (personal usage, no business tax exemption) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "AU" && + options.CustomerDetails.Address.PostalCode == "2000" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2020-families-org-annually" && item.Quantity == 6) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "personal-storage-gb-annually" && item.Quantity == 2) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_OrganizationWithoutGatewayIds_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = null, + GatewaySubscriptionId = null + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 5 + } + }; + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Organization does not have a subscription.", badRequest.Response); + + // Verify no Stripe API calls were made + await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_ZeroValuesExcluded_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0 // Should be excluded + }, + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 0, // Should be excluded entirely (including service accounts) + AdditionalServiceAccounts = 10 + } + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "US", PostalCode = "90210" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify only PM seats are included (storage=0 excluded, SM seats=0 so entire SM excluded) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Coupon == null)); + } + + #endregion +} diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs new file mode 100644 index 0000000000..bf7d093dc7 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -0,0 +1,292 @@ +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class PreviewPremiumTaxCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewPremiumTaxCommand _command; + + public PreviewPremiumTaxCommandTests() + { + _command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter); + } + + [Fact] + public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts() + { + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(5, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 5))); + } + + [Fact] + public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems() + { + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA" + }; + + var invoice = new Invoice + { + Tax = 250, + Total = 2750 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(2.50m, tax); + Assert.Equal(27.50m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits() + { + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(20, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 20))); + } + + [Fact] + public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo() + { + var billingAddress = new BillingAddress + { + Country = "AU", + PostalCode = "2000" + }; + + var invoice = new Invoice + { + Tax = 450, + Total = 4950 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(10, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.50m, tax); + Assert.Equal(49.50m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "AU" && + options.CustomerDetails.Address.PostalCode == "2000" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 10))); + } + + [Fact] + public async Task Run_PremiumNoTax_ReturnsZeroTax() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "97330" // Example of a tax-free jurisdiction + }; + + var invoice = new Invoice + { + Tax = 0, + Total = 3000 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(0.00m, tax); + Assert.Equal(30.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "97330" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_NegativeStorage_TreatedAsZero() + { + var billingAddress = new BillingAddress + { + Country = "FR", + PostalCode = "75001" + }; + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(-5, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "FR" && + options.CustomerDetails.Address.PostalCode == "75001" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + // Stripe amounts are in cents + var invoice = new Invoice + { + Tax = 123, // $1.23 + Total = 3123 // $31.23 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(1.23m, tax); + Assert.Equal(31.23m, total); + } +} diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs new file mode 100644 index 0000000000..a5970c79ab --- /dev/null +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -0,0 +1,198 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Subscriptions; + +using static StripeConstants; + +public class RestartSubscriptionCommandTests +{ + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + private readonly RestartSubscriptionCommand _command; + + public RestartSubscriptionCommandTests() + { + _command = new RestartSubscriptionCommand( + _organizationRepository, + _providerRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + } + + [Fact] + public async Task Run_SubscriptionNotCanceled_ReturnsBadRequest() + { + var organization = new Organization { Id = Guid.NewGuid() }; + + var subscription = new Subscription { Status = SubscriptionStatus.Active }; + _subscriberService.GetSubscription(organization).Returns(subscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response); + } + + [Fact] + public async Task Run_NoExistingSubscription_ReturnsBadRequest() + { + var organization = new Organization { Id = Guid.NewGuid() }; + + _subscriberService.GetSubscription(organization).Returns((Subscription)null); + + var result = await _command.Run(organization); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response); + } + + [Fact] + public async Task Run_Organization_Success_ReturnsNone() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId }; + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }, + new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 } + ] + }, + Metadata = new Dictionary { ["key"] = "value" } + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = currentPeriodEnd + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is((SubscriptionCreateOptions options) => + options.AutomaticTax.Enabled == true && + options.CollectionMethod == CollectionMethod.ChargeAutomatically && + options.Customer == "cus_123" && + options.Items.Count == 2 && + options.Items[0].Price == "price_1" && + options.Items[0].Quantity == 1 && + options.Items[1].Price == "price_2" && + options.Items[1].Quantity == 2 && + options.Metadata["key"] == "value" && + options.OffSession == true && + options.TrialPeriodDays == 0)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_new" && + org.Enabled == true && + org.ExpirationDate == currentPeriodEnd)); + } + + [Fact] + public async Task Run_Provider_Success_ReturnsNone() + { + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId }; + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] + }, + Metadata = new Dictionary() + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + }; + + _subscriberService.GetSubscription(provider).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(provider); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(prov => + prov.Id == providerId && + prov.GatewaySubscriptionId == "sub_new" && + prov.Enabled == true)); + } + + [Fact] + public async Task Run_User_Success_ReturnsNone() + { + var userId = Guid.NewGuid(); + var user = new User { Id = userId }; + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] + }, + Metadata = new Dictionary() + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = currentPeriodEnd + }; + + _subscriberService.GetSubscription(user).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(user); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + + await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == userId && + u.GatewaySubscriptionId == "sub_new" && + u.Premium == true && + u.PremiumExpirationDate == currentPeriodEnd)); + } +} diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs deleted file mode 100644 index 1de180cea1..0000000000 --- a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs +++ /dev/null @@ -1,541 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Commands; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Stripe; -using Xunit; -using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters; - -namespace Bit.Core.Test.Billing.Tax.Commands; - -public class PreviewTaxAmountCommandTests -{ - private readonly ILogger _logger = Substitute.For>(); - private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly IStripeAdapter _stripeAdapter = Substitute.For(); - private readonly ITaxService _taxService = Substitute.For(); - - private readonly PreviewTaxAmountCommand _command; - - public PreviewTaxAmountCommandTests() - { - _command = new PreviewTaxAmountCommand(_logger, _pricingClient, _stripeAdapter, _taxService); - } - - [Fact] - public async Task Run_WithSeatBasedPasswordManagerPlan_GetsTaxAmount() - { - // 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.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_WithNonSeatBasedPasswordManagerPlan_GetsTaxAmount() - { - // 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.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripePlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_WithSecretsManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.SecretsManager, - 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.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 2 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.SubscriptionDetails.Items[1].Price == plan.SecretsManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[1].Quantity == 1 && - options.Coupon == StripeConstants.CouponIDs.SecretsManagerStandalone && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithoutTaxId_GetsTaxAmount() - { - // 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.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "CA" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithTaxId_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345", - TaxId = "123456789" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) - .Returns("ca_st"); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "CA" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.CustomerDetails.TaxIds.Count == 1 && - options.CustomerDetails.TaxIds[0].Type == "ca_st" && - options.CustomerDetails.TaxIds[0].Value == "123456789" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithTaxId_UnknownTaxIdType_BadRequest() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345", - TaxId = "123456789" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) - .Returns((string)null); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT1); - 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); - } -} From e2f96be4dcf95935b899d75c25f06f1b63877c68 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Wed, 1 Oct 2025 08:55:03 -0700 Subject: [PATCH 139/163] refactor(sso-config-tweaks): [Auth/PM-933] Make Single Sign-On URL required regardless of EntityId (#6314) Makes the Single Sign-On URL required regardless of the EntityId --- .../Request/OrganizationSsoRequestModel.cs | 3 +- src/Core/Resources/SharedResources.en.resx | 2 +- .../OrganizationSsoRequestModelTests.cs | 313 ++++++++++++++++++ 3 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs diff --git a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs index fcf386d7ee..349bdebb88 100644 --- a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs +++ b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs @@ -121,7 +121,7 @@ public class SsoConfigurationDataRequest : IValidatableObject new[] { nameof(IdpEntityId) }); } - if (!Uri.IsWellFormedUriString(IdpEntityId, UriKind.Absolute) && string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl)) + if (string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl)) { yield return new ValidationResult(i18nService.GetLocalizedHtmlString("IdpSingleSignOnServiceUrlValidationError"), new[] { nameof(IdpSingleSignOnServiceUrl) }); @@ -139,6 +139,7 @@ public class SsoConfigurationDataRequest : IValidatableObject new[] { nameof(IdpSingleLogoutServiceUrl) }); } + // TODO: On server, make public certificate required for SAML2 SSO: https://bitwarden.atlassian.net/browse/PM-26028 if (!string.IsNullOrWhiteSpace(IdpX509PublicCert)) { // Validate the certificate is in a valid format diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 17b4489454..28ae70ca96 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -389,7 +389,7 @@ If SAML Binding Type is set to artifact, identity provider resolution service URL is required. - If Identity Provider Entity ID is not a URL, single sign on service URL is required. + Single sign on service URL is required. The configured authentication scheme is not valid: "{0}" diff --git a/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs new file mode 100644 index 0000000000..8348ba885d --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs @@ -0,0 +1,313 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Organizations; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Services; +using Bit.Core.Sso; +using Microsoft.Extensions.Localization; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request; + +public class OrganizationSsoRequestModelTests +{ + [Fact] + public void ToSsoConfig_WithOrganizationId_CreatesNewSsoConfig() + { + // Arrange + var organizationId = Guid.NewGuid(); + var model = new OrganizationSsoRequestModel + { + Enabled = true, + Identifier = "test-identifier", + Data = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = "https://example.com", + ClientId = "test-client", + ClientSecret = "test-secret" + } + }; + + // Act + var result = model.ToSsoConfig(organizationId); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.OrganizationId); + Assert.True(result.Enabled); + } + + [Fact] + public void ToSsoConfig_WithExistingConfig_UpdatesExistingConfig() + { + // Arrange + var organizationId = Guid.NewGuid(); + var existingConfig = new SsoConfig + { + Id = 1, + OrganizationId = organizationId, + Enabled = false + }; + + var model = new OrganizationSsoRequestModel + { + Enabled = true, + Identifier = "updated-identifier", + Data = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = "test-entity", + IdpSingleSignOnServiceUrl = "https://sso.example.com" + } + }; + + // Act + var result = model.ToSsoConfig(existingConfig); + + // Assert + Assert.Same(existingConfig, result); + Assert.Equal(organizationId, result.OrganizationId); + Assert.True(result.Enabled); + } +} + +public class SsoConfigurationDataRequestTests +{ + private readonly TestI18nService _i18nService; + private readonly ValidationContext _validationContext; + + public SsoConfigurationDataRequestTests() + { + _i18nService = new TestI18nService(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(II18nService)).Returns(_i18nService); + _validationContext = new ValidationContext(new object(), serviceProvider, null); + } + + [Fact] + public void ToConfigurationData_MapsProperties() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + MemberDecryptionType = MemberDecryptionType.KeyConnector, + Authority = "https://authority.example.com", + ClientId = "test-client-id", + ClientSecret = "test-client-secret", + IdpX509PublicCert = "-----BEGIN CERTIFICATE-----\nMIIC...test\n-----END CERTIFICATE-----", + SpOutboundSigningAlgorithm = null // Test default + }; + + // Act + var result = model.ToConfigurationData(); + + // Assert + Assert.Equal(SsoType.OpenIdConnect, result.ConfigType); + Assert.Equal(MemberDecryptionType.KeyConnector, result.MemberDecryptionType); + Assert.Equal("https://authority.example.com", result.Authority); + Assert.Equal("test-client-id", result.ClientId); + Assert.Equal("test-client-secret", result.ClientSecret); + Assert.Equal("MIIC...test", result.IdpX509PublicCert); // PEM headers stripped + Assert.Equal(SamlSigningAlgorithms.Sha256, result.SpOutboundSigningAlgorithm); // Default applied + Assert.Null(result.IdpArtifactResolutionServiceUrl); // Always null + } + + [Fact] + public void KeyConnectorEnabled_Setter_UpdatesMemberDecryptionType() + { + // Arrange + var model = new SsoConfigurationDataRequest(); + + // Act & Assert +#pragma warning disable CS0618 // Type or member is obsolete + model.KeyConnectorEnabled = true; + Assert.Equal(MemberDecryptionType.KeyConnector, model.MemberDecryptionType); + + model.KeyConnectorEnabled = false; + Assert.Equal(MemberDecryptionType.MasterPassword, model.MemberDecryptionType); +#pragma warning restore CS0618 // Type or member is obsolete + } + + // Validation Tests + [Fact] + public void Validate_OpenIdConnect_ValidData_NoErrors() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = "https://example.com", + ClientId = "test-client", + ClientSecret = "test-secret" + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("", "test-client", "test-secret", "AuthorityValidationError")] + [InlineData("https://example.com", "", "test-secret", "ClientIdValidationError")] + [InlineData("https://example.com", "test-client", "", "ClientSecretValidationError")] + public void Validate_OpenIdConnect_MissingRequiredFields_ReturnsErrors(string authority, string clientId, string clientSecret, string expectedError) + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = authority, + ClientId = clientId, + ClientSecret = clientSecret + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Single(results); + Assert.Equal(expectedError, results[0].ErrorMessage); + } + + [Fact] + public void Validate_Saml2_ValidData_NoErrors() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = "https://idp.example.com", + IdpSingleSignOnServiceUrl = "https://sso.example.com", + IdpSingleLogoutServiceUrl = "https://logout.example.com" + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("", "https://sso.example.com", "IdpEntityIdValidationError")] + [InlineData("not-a-valid-uri", "", "IdpSingleSignOnServiceUrlValidationError")] + public void Validate_Saml2_MissingRequiredFields_ReturnsErrors(string entityId, string signOnUrl, string expectedError) + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = entityId, + IdpSingleSignOnServiceUrl = signOnUrl + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Contains(results, r => r.ErrorMessage == expectedError); + } + + [Theory] + [InlineData("not-a-url")] + [InlineData("ftp://example.com")] + [InlineData("https://example.com