diff --git a/.editorconfig b/.editorconfig index fd68808456..71dd40de98 100644 --- a/.editorconfig +++ b/.editorconfig @@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * dotnet_naming_symbols.any_async_methods.required_modifiers = async -dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_prefix = dotnet_naming_style.end_in_async.required_suffix = Async dotnet_naming_style.end_in_async.capitalization = pascal_case -dotnet_naming_style.end_in_async.word_separator = +dotnet_naming_style.end_in_async.word_separator = # Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items. dotnet_diagnostic.CS0618.severity = suggestion @@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion # Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 dotnet_diagnostic.IDE0005.severity = warning +# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304 +dotnet_diagnostic.CA1304.severity = warning + +# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305 +dotnet_diagnostic.CA1305.severity = warning + # CSharp code style settings: [*.cs] # Prefer "var" everywhere diff --git a/.vscode/launch.json b/.vscode/launch.json index c407ba5604..74115dcc86 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -69,6 +69,28 @@ "preLaunchTask": "buildFullServer", "stopAll": true }, + { + "name": "Full Server with Seeder API", + "configurations": [ + "run-Admin", + "run-API", + "run-Events", + "run-EventsProcessor", + "run-Identity", + "run-Sso", + "run-Icons", + "run-Billing", + "run-Notifications", + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "AA_compounds", + "order": 6 + }, + "preLaunchTask": "buildFullServerWithSeederApi", + "stopAll": true + }, { "name": "Self Host: Bit", "configurations": [ @@ -204,6 +226,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API", + "configurations": [ + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "cloud", + }, + "preLaunchTask": "buildSeederAPI", + }, { "name": "Admin Self Host", "configurations": [ @@ -270,6 +303,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API Self Host", + "configurations": [ + "run-SeederAPI-SelfHost" + ], + "presentation": { + "hidden": false, + "group": "self-host", + }, + "preLaunchTask": "buildSeederAPI", + } ], "configurations": [ // Configurations represent run-only scenarios so that they can be used in multiple compounds @@ -311,6 +355,25 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Billing", "presentation": { @@ -488,6 +551,27 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI-SelfHost", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5048", + "developSelfHosted": "true", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Admin-SelfHost", "presentation": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 567f9b6e58..07a55fdeb3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,6 +43,21 @@ "label": "buildFullServer", "hide": true, "dependsOrder": "sequence", + "dependsOn": [ + "buildAdmin", + "buildAPI", + "buildEventsProcessor", + "buildIdentity", + "buildSso", + "buildIcons", + "buildBilling", + "buildNotifications" + ], + }, + { + "label": "buildFullServerWithSeederApi", + "hide": true, + "dependsOrder": "sequence", "dependsOn": [ "buildAdmin", "buildAPI", @@ -52,6 +67,7 @@ "buildIcons", "buildBilling", "buildNotifications", + "buildSeederAPI" ], }, { @@ -89,6 +105,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -102,6 +121,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -115,6 +137,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -128,6 +153,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -141,6 +169,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -154,6 +185,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -167,6 +201,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -180,6 +217,29 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "buildSeederAPI", + "hide": true, + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/util/SeederApi/SeederApi.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -197,6 +257,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -214,6 +277,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -224,6 +290,9 @@ "label": "test", "type": "shell", "command": "dotnet test", + "options": { + "cwd": "${workspaceFolder}" + }, "group": { "kind": "test", "isDefault": true diff --git a/Directory.Build.props b/Directory.Build.props index db3ccf40f5..9438ef3351 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,21 +13,21 @@ true - + - + 18.0.1 - + 2.6.6 - + 2.5.6 - + 6.0.0 - + 5.1.0 - + 4.18.1 - + 4.18.1 - \ No newline at end of file + diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 6786ad610c..ae9571a4a5 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29102.190 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}" EndProject @@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}" ProjectSection(SolutionItems) = preProject + .dockerignore = .dockerignore + .editorconfig = .editorconfig + .gitignore = .gitignore + CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props global.json = global.json - .gitignore = .gitignore - README.md = README.md - .editorconfig = .editorconfig - TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md - SECURITY.md = SECURITY.md - LICENSE_FAQ.md = LICENSE_FAQ.md - LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt - LICENSE_AGPL.txt = LICENSE_AGPL.txt LICENSE.txt = LICENSE.txt - CONTRIBUTING.md = CONTRIBUTING.md - .dockerignore = .dockerignore + LICENSE_AGPL.txt = LICENSE_AGPL.txt + LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt + LICENSE_FAQ.md = LICENSE_FAQ.md + README.md = README.md + SECURITY.md = SECURITY.md + TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}" @@ -134,10 +134,16 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -350,10 +356,22 @@ Global {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -410,7 +428,10 @@ Global {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index 2a84faa8dd..a912562f72 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -44,6 +44,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index afbef321a9..dde2ac7a46 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -462,6 +462,7 @@ public class AccountController : Controller // FIXME: Update this file to be null safe and then delete the line below #nullable disable var provider = result.Properties.Items["scheme"]; + //Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception var orgId = new Guid(provider); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); if (ssoConfig == null || !ssoConfig.Enabled) @@ -615,7 +616,7 @@ public class AccountController : Controller // 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 + // We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed // with authentication. await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser); diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index 2f83f3dad0..a2f363d533 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -41,6 +41,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj index 4fc79f2025..d0d329397c 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj +++ b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj @@ -1,35 +1,37 @@ - - - - false - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - true - PreserveNewest - Never - - - + + + + false + + $(WarningsNotAsErrors);CA1305 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs new file mode 100644 index 0000000000..7a1c9f9628 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs @@ -0,0 +1,952 @@ +using System.Net; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Sso.IntegrationTest.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using NSubstitute; +using Xunit; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Controllers; + +public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture +{ + private readonly SsoApplicationFactory _factory = factory; + + /* + * Test to verify the /Account/ExternalCallback endpoint exists and is reachable. + */ + [Fact] + public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Verify the endpoint is accessible (even if it fails due to missing auth) + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - The endpoint should exist and return 500 (not 404) due to missing authentication + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + /* + * Test to verify calling /Account/ExternalCallback without an authentication cookie + * results in an error as expected. + */ + [Fact] + public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Call ExternalCallback without proper authentication setup + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because there's no external authentication cookie + Assert.False(response.IsSuccessStatusCode); + // The endpoint will throw an exception when authentication fails + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag + */ + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError + ( + bool featureFlagEnabled + ) + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled); + services.AddSingleton(featureService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback simulating failed authentication. + */ + [Fact] + public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithFailedAuthentication() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled. + */ + [Fact] + public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because SSO config is disabled + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value + * but the authentication response has a missing or invalid ACR claim. + */ + [Fact] + public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.SetData( + new SsoConfigurationData + { + ExpectedReturnAcrValue = "urn:expected:acr:value" + })) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because ACR claim is missing or invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the authentication response + * does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn). + */ + [Fact] + public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .OmitProviderUserId() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); ; + + // Assert - Should fail because no user ID claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Unknown userid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when no email claim is found + * and the providerUserId cannot be used as a fallback email (doesn't contain @). + */ + [Fact] + public async Task ExternalCallback_WithNoEmailClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no email claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector but has no org user record (was removed from organization). + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector and has an org user record in the invited status. + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => { }) + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but the Org user is in the invited status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * (not using Key Connector) has no org user record - they were removed from the organization. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user exists but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * has an org user record with Invited status - they must accept the invite first. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user must accept invite before using SSO + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("you must first log in using your master password", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and cannot auto-scale because it's a self-hosted instance. + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; // Organization has seat limit + }) + .AsSelfHosted() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no seats available and cannot auto-scale on self-hosted + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and auto-scaling fails (e.g., billing issue, max seats reached). + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; + org.MaxAutoscaleSeats = 5; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because auto-adding seats failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when email cannot be found + * during new user provisioning (Scenario 2) after bypassing the first email check + * via manual linking path (userIdentifier is set). + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier("") + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because email cannot be found during new user provisioning + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status. + * This tests defensive code that handles future enum values or data corruption scenarios. + * We simulate this by casting an invalid integer to OrganizationUserStatusType. + */ + [Fact] + public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user status is unknown/invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("is in an unknown state", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + // Note: "User should be found ln 304" appears to be unreachable defensive code. + // CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception, + // so possibleSsoLinkedUser cannot be null when the feature flag check executes. + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * is malformed (doesn't contain comma separator for userId,token format). + * There is only a single test case here but in the future we may need to expand the + * tests to cover other invalid formats. + */ + [Theory] + [BitAutoData("No-Comas-Identifier")] + public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError( + string UserIdentifier + ) + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier(UserIdentifier) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because userIdentifier format is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid user identifier", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * contains valid userId but invalid/mismatched token. + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - The userIdentifier in the auth result must contain a userId that matches a user in the system + * - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard) + * - This means we cannot pre-set a User.Id before database insertion + * - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService) + * - Therefore, we cannot coordinate the userId between the auth mock and the seeded user + * - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError() + { + // Arrange + var organizationId = Guid.NewGuid(); + var providerUserId = Guid.NewGuid().ToString(); + var userId = Guid.NewGuid(); + var testEmail = "test_user@integration.test"; + var testName = "Test User"; + // Valid format but token won't verify + var userIdentifier = $"{userId},invalid-token"; + + var claimedUser = new User + { + Id = userId, + Email = testEmail, + Name = testName + }; + + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + Enabled = true, + UseSso = true + }; + + var ssoConfig = new SsoConfig + { + OrganizationId = organizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + services.AddSingleton(featureService); + + // Mock organization repository + var orgRepo = Substitute.For(); + orgRepo.GetByIdAsync(organizationId).Returns(organization); + orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization); + services.AddSingleton(orgRepo); + + // Mock SSO config repository + var ssoConfigRepo = Substitute.For(); + ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig); + services.AddSingleton(ssoConfigRepo); + + // Mock user repository - no existing user via SSO + var userRepo = Substitute.For(); + userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null); + services.AddSingleton(userRepo); + + // Mock user service - returns user for manual linking lookup + var userService = Substitute.For(); + userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser); + services.AddSingleton(userService); + + // Mock UserManager to return false for token verification + var userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + userManager.VerifyUserTokenAsync( + claimedUser, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + services.AddSingleton(userManager); + + // Mock authentication service with userIdentifier that has valid format but invalid token + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because token verification failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Supplied userId and token did not match", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user state is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + + /* + * Test to verify /Account/ExternalCallback returns error when user is found via SSO + * but has no organization user record (with feature flag enabled). + */ + [Fact] + public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithSsoUser() + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user cannot be found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization user", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the provider scheme + * is not a valid GUID (SSOProviderIsNotAnOrgId). + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - Organization.Id is of type Guid and cannot be set to a non-GUID value + * - The auth mock scheme must be a non-GUID string to trigger this error path + * - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception + * before reaching the organization lookup exception. + */ + [Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")] + public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError() + { + // Arrange + var invalidScheme = "not-a-valid-guid"; + var providerUserId = Guid.NewGuid().ToString(); + var testEmail = "test@example.com"; + var testName = "Test User"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Mock authentication service with invalid (non-GUID) scheme + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because provider is not a valid organization GUID + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found from identifier.", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the organization ID + * in the auth result does not match any organization in the database. + * NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency: + * - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found. + */ + [Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")] + public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNonExistentOrganizationInAuth() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because organization cannot be found by the ID in auth result + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing + * SSO-linked user logs in (user exists in SsoUser table). + */ + [Fact] + public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess() + { + // Arrange - User with SSO link already exists + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning + * a new user (user doesn't exist, gets created automatically). + */ + [Fact] + public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess() + { + // Arrange - No user, no org user - JIT provisioning will create both + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with a valid (Confirmed) organization user status logs in via SSO for the first time. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with confirmed org user status, no SSO link yet + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Confirmed; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with Accepted organization user status logs in via SSO. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with accepted org user status + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Accepted; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status + * when the client is a native application (uses custom URI scheme like "bitwarden://callback"). + * Native clients get a different response for better UX - a 200 with redirect view instead of 302. + * See AccountController lines 371-378. + */ + [Fact] + public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status() + { + // Arrange - Existing SSO user with native client context + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .AsNativeClient() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Native clients get 200 status with a redirect view instead of 302 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The Location header should be empty for native clients (set in controller) + // and the response should contain the redirect view + var content = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(content); // View content should be present + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json new file mode 100644 index 0000000000..63637a5304 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Sso.IntegrationTest": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59973;http://localhost:59974" + } + } +} \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj new file mode 100644 index 0000000000..42d0743d51 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + + \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs new file mode 100644 index 0000000000..656c045284 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs @@ -0,0 +1,11 @@ +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.Sso.IntegrationTest.Utilities; + +public class SsoApplicationFactory : WebApplicationFactoryBase +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs new file mode 100644 index 0000000000..95f2387af2 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs @@ -0,0 +1,327 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using NSubstitute; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Utilities; + +/// +/// Contains the factory and all entities created by for use in integration tests. +/// +public record SsoTestData( + SsoApplicationFactory Factory, + Organization? Organization, + User? User, + OrganizationUser? OrganizationUser, + SsoConfig? SsoConfig, + SsoUser? SsoUser); + +/// +/// Builder for creating SSO test data with seeded database entities. +/// +public class SsoTestDataBuilder +{ + /// + /// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider. + /// + private string? _userIdentifier; + private Action? _organizationConfig; + private Action? _userConfig; + private Action? _orgUserConfig; + private Action? _ssoConfigConfig; + private Action? _ssoUserConfig; + private Action? _featureFlagConfig; + + private bool _includeUser = false; + private bool _includeSsoUser = false; + private bool _includeOrganizationUser = false; + private bool _includeSsoConfig = false; + private bool _successfulAuth = true; + private bool _withNullEmail = false; + private bool _isSelfHosted = false; + private bool _includeProviderUserId = true; + private bool _useNonExistentOrgInAuth = false; + private bool _isNativeClient = false; + + public SsoTestDataBuilder WithOrganization(Action configure) + { + _organizationConfig = configure; + return this; + } + + public SsoTestDataBuilder WithUser(Action? configure = null) + { + _includeUser = true; + _userConfig = configure; + return this; + } + + public SsoTestDataBuilder WithOrganizationUser(Action? configure = null) + { + _includeOrganizationUser = true; + _orgUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoConfig(Action? configure = null) + { + _includeSsoConfig = true; + _ssoConfigConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoUser(Action? configure = null) + { + _includeSsoUser = true; + _ssoUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFeatureFlags(Action configure) + { + _featureFlagConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFailedAuthentication() + { + _successfulAuth = false; + return this; + } + + public SsoTestDataBuilder WithNullEmail() + { + _withNullEmail = true; + return this; + } + + public SsoTestDataBuilder WithUserIdentifier(string userIdentifier) + { + _userIdentifier = userIdentifier; + return this; + } + + public SsoTestDataBuilder OmitProviderUserId() + { + _includeProviderUserId = false; + return this; + } + + public SsoTestDataBuilder AsSelfHosted() + { + _isSelfHosted = true; + return this; + } + + /// + /// Causes the auth result to use a different (non-existent) organization ID than what is seeded + /// in the database. This simulates the "organization not found" scenario. + /// + public SsoTestDataBuilder WithNonExistentOrganizationInAuth() + { + _useNonExistentOrgInAuth = true; + return this; + } + + /// + /// Configures the test to simulate a native client (non-browser) OIDC flow. + /// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https. + /// This causes ExternalCallback to return a View with 200 status instead of a redirect. + /// + public SsoTestDataBuilder AsNativeClient() + { + _isNativeClient = true; + return this; + } + + public async Task BuildAsync() + { + // Create factory + var factory = new SsoApplicationFactory(); + + // Pre-generate IDs and values needed for auth mock (before accessing Services) + var organizationId = Guid.NewGuid(); + // Use a different org ID in auth if testing "organization not found" scenario + var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId; + var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : ""; + var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com"; + var userName = "TestUser"; + + // 1. Configure mocked authentication service BEFORE accessing Services + factory.SubstituteService(authService => + { + if (_successfulAuth) + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build( + authOrganizationId, + providerUserId, + userEmail, + userName, + acrValue: null, + _userIdentifier)); + } + else + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(AuthenticateResult.Fail("External authentication error")); + } + }); + + // 1.a Configure GlobalSettings for Self-Hosted and seat limit + factory.SubstituteService(globalSettings => + { + globalSettings.SelfHosted.Returns(_isSelfHosted); + }); + + // 1.b configure setting feature flags + _featureFlagConfig?.Invoke(factory); + + // 1.c Configure IIdentityServerInteractionService for native client flow + if (_isNativeClient) + { + factory.SubstituteService(interaction => + { + // Native clients have redirect URIs that don't start with http/https + // e.g., "bitwarden://callback" or "com.bitwarden.app://callback" + var authorizationRequest = new AuthorizationRequest + { + RedirectUri = "bitwarden://sso-callback" + }; + interaction.GetAuthorizationContextAsync(Arg.Any()) + .Returns(authorizationRequest); + }); + } + + if (!_successfulAuth) + { + return new SsoTestData(factory, null!, null!, null!, null!, null!); + } + + // 2. Create Organization with defaults (using pre-generated ID) + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + BillingEmail = "billing@test.com", + Plan = "Enterprise", + Enabled = true, + UseSso = true + }; + _organizationConfig?.Invoke(organization); + + var orgRepo = factory.Services.GetRequiredService(); + organization = await orgRepo.CreateAsync(organization); + + // 3. Create User with defaults (using pre-generated values) + User? user = null; + if (_includeUser) + { + user = new User + { + Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev", + Name = userName, + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + _userConfig?.Invoke(user); + + var userRepo = factory.Services.GetRequiredService(); + user = await userRepo.CreateAsync(user); + } + + // 4. Create OrganizationUser linking them + OrganizationUser? orgUser = null; + if (_includeOrganizationUser) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + _orgUserConfig?.Invoke(orgUser); + + var orgUserRepo = factory.Services.GetRequiredService(); + orgUser = await orgUserRepo.CreateAsync(orgUser); + } + + // 4.a Create many OrganizationUser to test seat count logic + if (organization.Seats > 1) + { + var orgUserRepo = factory.Services.GetRequiredService(); + var userRepo = factory.Services.GetRequiredService(); + var additionalOrgUsers = new List(); + for (var i = 1; i <= organization.Seats; i++) + { + var additionalUser = new User + { + Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev", + Name = $"AdditionalUser{i}", + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + var createdAdditionalUser = await userRepo.CreateAsync(additionalUser); + + var additionalOrgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = createdAdditionalUser.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + additionalOrgUsers.Add(additionalOrgUser); + } + await orgUserRepo.CreateManyAsync(additionalOrgUsers); + } + + // 5. Create SsoConfig, if ssoConfigConfig is not null + SsoConfig? ssoConfig = null; + if (_includeSsoConfig) + { + ssoConfig = new SsoConfig + { + OrganizationId = authOrganizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + _ssoConfigConfig?.Invoke(ssoConfig); + + var ssoConfigRepo = factory.Services.GetRequiredService(); + ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig); + } + + // 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId) + SsoUser? ssoUser = null; + if (_includeSsoUser) + { + ssoUser = new SsoUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + ExternalId = providerUserId + }; + _ssoUserConfig?.Invoke(ssoUser); + + var ssoUserRepo = factory.Services.GetRequiredService(); + ssoUser = await ssoUserRepo.CreateAsync(ssoUser); + } + + return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs new file mode 100644 index 0000000000..72f5738ad9 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Bit.Core; +using Duende.IdentityModel; +using Microsoft.AspNetCore.Authentication; + +namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities; + +/// +/// Creates a mock for use in tests requiring a valid external authentication result. +/// +internal static class MockSuccessfulAuthResult +{ + /// + /// Since this tests the external Authentication flow, only the OrganizationId is strictly required. + /// However, some tests may require additional claims to be present, so they can be optionally added. + /// + /// + /// + /// + /// + /// + /// + /// + public static AuthenticateResult Build( + Guid organizationId, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier); + } + + /// + /// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios + /// where the scheme is not a valid GUID. + /// + public static AuthenticateResult Build( + string scheme, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + var claims = new List(); + + if (!string.IsNullOrEmpty(email)) + { + claims.Add(new Claim(JwtClaimTypes.Email, email)); + } + + if (!string.IsNullOrEmpty(providerUserId)) + { + claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId)); + } + + if (!string.IsNullOrEmpty(name)) + { + claims.Add(new Claim(JwtClaimTypes.Name, name)); + } + + if (!string.IsNullOrEmpty(acrValue)) + { + claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue)); + } + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External")); + var properties = new AuthenticationProperties + { + Items = + { + ["scheme"] = scheme, + ["return_url"] = "~/", + ["state"] = "test-state", + ["user_identifier"] = userIdentifier ?? string.Empty + } + }; + + var ticket = new AuthenticationTicket( + principal, + properties, + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 old mode 100644 new mode 100755 index 96dff04632..5013ca8bac --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -2,7 +2,7 @@ # Helper script for applying the same user secrets to each project param ( [switch]$clear, - [Parameter(ValueFromRemainingArguments = $true, Position=1)] + [Parameter(ValueFromRemainingArguments = $true, Position = 1)] $cmdArgs ) @@ -16,17 +16,18 @@ if ($clear -eq $true) { } $projects = @{ - Admin = "../src/Admin" - Api = "../src/Api" - Billing = "../src/Billing" - Events = "../src/Events" - EventsProcessor = "../src/EventsProcessor" - Icons = "../src/Icons" - Identity = "../src/Identity" - Notifications = "../src/Notifications" - Sso = "../bitwarden_license/src/Sso" - Scim = "../bitwarden_license/src/Scim" + Admin = "../src/Admin" + Api = "../src/Api" + Billing = "../src/Billing" + Events = "../src/Events" + EventsProcessor = "../src/EventsProcessor" + Icons = "../src/Icons" + Identity = "../src/Identity" + Notifications = "../src/Notifications" + Sso = "../bitwarden_license/src/Sso" + Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" + SeederApi = "../util/SeederApi" } foreach ($key in $projects.keys) { diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index cd30e841b4..b815ddea82 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -2,6 +2,8 @@ bitwarden-Admin + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 87d68a7ac6..6c0a644ee6 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -65,6 +65,7 @@ public class Startup default: break; } + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5cdd857f3f..90d02a46a1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)) - { - return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); - } - var currentUserId = _userService.GetProperUserId(User); if (currentUserId == null) { diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index dd27de2e63..d25b989d11 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -4,6 +4,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml true + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 33a7e52791..75e96ebc66 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel existingEmergencyAccess.KeyEncrypted = KeyEncrypted; } existingEmergencyAccess.Type = Type; - existingEmergencyAccess.WaitTimeDays = WaitTimeDays; + existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays; return existingEmergencyAccess; } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index e3410de503..c90b927bee 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -22,7 +22,7 @@ public class AccountsController( IFeatureService featureService, ILicensingService licensingService) : Controller { - // TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work. + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, @@ -61,7 +61,7 @@ public class AccountsController( } } - // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorageAsync([FromBody] StorageRequestModel model) @@ -118,7 +118,7 @@ public class AccountsController( user.IsExpired()); } - // TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("reinstate-premium")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostReinstateAsync() @@ -131,10 +131,4 @@ public class AccountsController( await userService.ReinstatePremiumAsync(user); } - - private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) - { - var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); - return organizationsClaimingUser.Select(o => o.Id); - } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index d1e9b9206a..6c56d6db3a 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext; public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, + IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IGetUserLicenseQuery getUserLicenseQuery, + IReinstateSubscriptionCommand reinstateSubscriptionCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand, IUpdatePremiumStorageCommand updatePremiumStorageCommand, IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController @@ -91,10 +95,30 @@ public class AccountBillingVNextController( return TypedResults.Ok(response); } - [HttpPut("storage")] + [HttpGet("subscription")] [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] [InjectUser] - public async Task UpdateStorageAsync( + public async Task GetSubscriptionAsync( + [BindNever] User user) + { + var subscription = await getBitwardenSubscriptionQuery.Run(user); + return TypedResults.Ok(subscription); + } + + [HttpPost("subscription/reinstate")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task ReinstateSubscriptionAsync( + [BindNever] User user) + { + var result = await reinstateSubscriptionCommand.Run(user); + return Handle(result); + } + + [HttpPut("subscription/storage")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task UpdateSubscriptionStorageAsync( [BindNever] User user, [FromBody] StorageUpdateRequest request) { diff --git a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs index 0b18fc1e6f..fe0c8e9e17 100644 --- a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs +++ b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs @@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject /// Must be between 0 and the maximum allowed (minus base storage). /// [Required] - [Range(0, 99)] public short AdditionalStorageGb { get; set; } public IEnumerable Validate(ValidationContext validationContext) @@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject { yield return new ValidationResult( "Additional storage cannot be negative.", - new[] { nameof(AdditionalStorageGb) }); + [nameof(AdditionalStorageGb)]); } if (AdditionalStorageGb > 99) { yield return new ValidationResult( "Maximum additional storage is 99 GB.", - new[] { nameof(AdditionalStorageGb) }); + [nameof(AdditionalStorageGb)]); } } } diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 32d12aa416..a357264081 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -10,6 +10,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Models.Response; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page public class SubscriptionResponseModel : ResponseModel { diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2f16470cd4..b201cef0f3 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -85,6 +85,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 69999dc795..27ee9a7ce3 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -3,12 +3,13 @@ bitwarden-Billing + + $(WarningsNotAsErrors);CA1305 false - false false diff --git a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs index 101b0e26b9..0db498844e 100644 --- a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs +++ b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs @@ -1,12 +1,13 @@ using Bit.Core.Billing.Constants; +using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class InvoiceCreatedHandler( + IBraintreeService braintreeService, ILogger logger, IStripeEventService stripeEventService, - IStripeEventUtilityService stripeEventUtilityService, IProviderEventService providerEventService) : IInvoiceCreatedHandler { @@ -29,9 +30,9 @@ public class InvoiceCreatedHandler( { try { - var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]); - var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false; + var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId"); if (usingPayPal && invoice is { @@ -39,13 +40,12 @@ public class InvoiceCreatedHandler( Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: - "subscription_create" or "subscription_cycle" or "automatic_pending_invoice_item_invoice", - Parent.SubscriptionDetails: not null + Parent.SubscriptionDetails.Subscription: not null }) { - await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); + await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice); } } catch (Exception exception) diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 30f4f5f562..f5f98bfd53 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -48,6 +48,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // PayPal IPN Client services.AddHttpClient(); diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index d74fb4f138..5ec9dc255a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization UseApi = UseApi, UseResetPassword = UseResetPassword, UseSecretsManager = UseSecretsManager, + UsePasswordManager = UsePasswordManager, SelfHost = SelfHost, UsersGetPremium = UsersGetPremium, UseCustomPermissions = UseCustomPermissions, @@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, UseDisableSmAdsForUsers = UseDisableSmAdsForUsers, UsePhishingBlocker = UsePhishingBlocker, + UseOrganizationDomains = UseOrganizationDomains, + UseAutomaticUserConfirmation = UseAutomaticUserConfirmation, }; } } diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs index 8477efff26..3c8f498403 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs @@ -1,6 +1,6 @@ - + @@ -8,808 +8,807 @@ - - - - - - - + + + + + + + - - - - + + + + - + - - - - - -
- - - - - -
- + + + + + +
+ + + + + +
+ - + - - - -
- - - - - - - - -
- - - - - -
- - - - - - +
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ +
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Organization Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - +
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily access and share passwords with your team.
- -
- - + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Organization Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily access and share passwords with your team.
+ +
+ + - + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + +
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - +
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + +
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - +
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ +
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa - Barbara, CA, USA -

-

- Always confirm you are on a trusted Bitwarden domain before logging - in:
- bitwarden.com | - Learn why we include this -

- -
- -
- - -
- -
- - - - - -
- - + +
+ + + + + +
+ + - \ No newline at end of file diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs index cbe09d3e93..c0f838e0c7 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs @@ -1,6 +1,6 @@ - + @@ -8,976 +8,976 @@ - - - - - - - + + + + + + + - - - - + + + + - + - - - - - -
- - - - - -
- + + + + + +
+ + + + + +
+ - + - - - -
- - - - - - - - -
- - - - - -
- - - - - - +
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ +
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Collections Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
You can access passwords {{OrganizationName}} has shared with you.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - +
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily share passwords with friends, family, or coworkers.
- -
- - + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Collections Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
You can access passwords {{OrganizationName}} has shared with you.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily share passwords with friends, family, or coworkers.
+ +
+ + - + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + +
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - +
- - -
- - - - - - - - - - - - - -
- -
Download Bitwarden on all devices
- +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
Download Bitwarden on all devices
+ +
+ +
Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Download on the App Store + + + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Get it on Google Play + + + +
+ +
+ +
+ + +
+ + +
+ +
+ + +
- -
Already using the browser extension? - Download the Bitwarden mobile app from the - App Store - or Google Play - to quickly save logins and autofill forms on the go.
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - - - Download on the App Store - - - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - - Get it on Google Play - - - -
- -
- -
- - -
- - -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - +
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + +
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - +
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ +
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa - Barbara, CA, USA -

-

- Always confirm you are on a trusted Bitwarden domain before logging - in:
- bitwarden.com | - Learn why we include this -

- -
- -
- - -
- -
- - - - - -
- - + +
+ + + + + +
+ + - \ No newline at end of file + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs index 7b5541c3ce..313c01af7c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs @@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand { Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); - Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs index 7aa67f0813..750ebf2518 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs @@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand( await organizationUserRepository.RevokeAsync(organizationUser.Id); organizationUser.Status = OrganizationUserStatusType.Revoked; } - - public async Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId) - { - var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - - if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var deletingUserIsOwner = false; - if (revokingUserId.HasValue) - { - deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId); - } - - var result = new List>(); - - foreach (var organizationUser in filteredUsers) - { - try - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && - !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - if (organizationUser.UserId.HasValue) - { - await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - - result.Add(Tuple.Create(organizationUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(organizationUser, e.Message)); - } - } - - return result; - } } diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index d855126468..36aaf46a8c 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject public string KeyEncrypted { get; set; } public EmergencyAccessType Type { get; set; } public EmergencyAccessStatusType Status { get; set; } - public int WaitTimeDays { get; set; } + public short WaitTimeDays { get; set; } public DateTime? RecoveryInitiatedDate { get; set; } public DateTime? LastNotificationDate { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index f02b1376d6..6552f4bc69 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -78,7 +78,7 @@ public class EmergencyAccessService : IEmergencyAccessService Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, Type = accessType, - WaitTimeDays = waitTime, + WaitTimeDays = (short)waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index d25962a7ba..e9c34d7e06 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -42,6 +42,7 @@ public static class StripeConstants public static class ErrorCodes { public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid"; + public const string InvoiceUpcomingNone = "invoice_upcoming_none"; public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded"; public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch"; public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout"; @@ -65,8 +66,10 @@ public static class StripeConstants public static class MetadataKeys { public const string BraintreeCustomerId = "btCustomerId"; + public const string BraintreeTransactionId = "btTransactionId"; public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; + public const string PayPalTransactionId = "btPayPalTransactionId"; public const string PreviousAdditionalStorage = "previous_additional_storage"; public const string PreviousPeriodEndDate = "previous_period_end_date"; public const string PreviousPremiumPriceId = "previous_premium_price_id"; diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs index 9e6fa69832..20421bc2af 100644 --- a/src/Core/Billing/Enums/PlanCadenceType.cs +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -1,7 +1,11 @@ -namespace Bit.Core.Billing.Enums; +using System.Runtime.Serialization; + +namespace Bit.Core.Billing.Enums; public enum PlanCadenceType { + [EnumMember(Value = "annually")] Annually, + [EnumMember(Value = "monthly")] Monthly } diff --git a/src/Core/Billing/Extensions/DiscountExtensions.cs b/src/Core/Billing/Extensions/DiscountExtensions.cs new file mode 100644 index 0000000000..6d5b91bd89 --- /dev/null +++ b/src/Core/Billing/Extensions/DiscountExtensions.cs @@ -0,0 +1,12 @@ +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class DiscountExtensions +{ + public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem) + => discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id); + + public static bool IsValid(this Discount? discount) + => discount?.Coupon?.Valid ?? false; +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d121ab04aa..c61c4e6279 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; +using Bit.Core.Services; +using Bit.Core.Services.Implementations; namespace Bit.Core.Billing.Extensions; @@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index 1dfd786210..000edda1c2 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -1,9 +1,11 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; @@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman } var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + + // If the license has a Token (claims-based), extract all properties from claims BEFORE validation + // This ensures that CanUseLicense validation has access to the correct values from claims + // Otherwise, fall back to using the properties already on the license object (backward compatibility) + if (claimsPrincipal != null) + { + license.Name = claimsPrincipal.GetValue(OrganizationLicenseConstants.Name); + license.BillingEmail = claimsPrincipal.GetValue(OrganizationLicenseConstants.BillingEmail); + license.BusinessName = claimsPrincipal.GetValue(OrganizationLicenseConstants.BusinessName); + license.PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType); + license.Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats); + license.MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections); + license.UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies); + license.UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso); + license.UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector); + license.UseScim = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseScim); + license.UseGroups = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseGroups); + license.UseDirectory = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDirectory); + license.UseEvents = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseEvents); + license.UseTotp = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseTotp); + license.Use2fa = claimsPrincipal.GetValue(OrganizationLicenseConstants.Use2fa); + license.UseApi = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseApi); + license.UseResetPassword = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseResetPassword); + license.Plan = claimsPrincipal.GetValue(OrganizationLicenseConstants.Plan); + license.SelfHost = claimsPrincipal.GetValue(OrganizationLicenseConstants.SelfHost); + license.UsersGetPremium = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsersGetPremium); + license.UseCustomPermissions = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseCustomPermissions); + license.Enabled = claimsPrincipal.GetValue(OrganizationLicenseConstants.Enabled); + license.Expires = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + license.LicenseKey = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseKey); + license.UsePasswordManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePasswordManager); + license.UseSecretsManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSecretsManager); + license.SmSeats = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmSeats); + license.SmServiceAccounts = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmServiceAccounts); + license.UseRiskInsights = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseRiskInsights); + license.UseOrganizationDomains = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseOrganizationDomains); + license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies); + license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation); + license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDisableSmAdsForUsers); + license.UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker); + license.MaxStorageGb = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxStorageGb); + license.InstallationId = claimsPrincipal.GetValue(OrganizationLicenseConstants.InstallationId); + license.LicenseType = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseType); + license.Issued = claimsPrincipal.GetValue(OrganizationLicenseConstants.Issued); + license.Refresh = claimsPrincipal.GetValue(OrganizationLicenseConstants.Refresh); + license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + license.Trial = claimsPrincipal.GetValue(OrganizationLicenseConstants.Trial); + license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue(OrganizationLicenseConstants.LimitCollectionCreationDeletion); + license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems); + } + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) && selfHostedOrganization.CanUseLicense(license, out exception); @@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman throw new BadRequestException(exception); } - var useAutomaticUserConfirmation = claimsPrincipal? - .GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false; - - selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; - license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; - await WriteLicenseFileAsync(selfHostedOrganization, license); await UpdateOrganizationAsync(selfHostedOrganization, license); } diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index ed60e2f11c..d52c79c1ee 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Push; @@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand public class CreatePremiumCloudHostedSubscriptionCommand( IBraintreeGateway braintreeGateway, + IBraintreeService braintreeService, IGlobalSettings globalSettings, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, @@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ValidateLocation = ValidateTaxLocationTiming.Immediately } }; + return await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } @@ -351,14 +354,18 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); - if (usingPayPal) + if (!usingPayPal) { - await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions - { - AutoAdvance = false - }); + return subscription; } + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false + }); + + await braintreeService.PayInvoice(new UserId(userId), invoice); + return subscription; } } diff --git a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs index 610c112e08..176c77bf57 100644 --- a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -10,6 +11,8 @@ using Stripe; namespace Bit.Core.Billing.Premium.Commands; +using static StripeConstants; + /// /// Updates the storage allocation for a premium user's subscription. /// Handles both increases and decreases in storage in an idempotent manner. @@ -34,14 +37,14 @@ public class UpdatePremiumStorageCommand( { public Task> Run(User user, short additionalStorageGb) => HandleAsync(async () => { - if (!user.Premium) + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) { return new BadRequest("User does not have a premium subscription."); } if (!user.MaxStorageGb.HasValue) { - return new BadRequest("No access to storage."); + return new BadRequest("User has no access to storage."); } // Fetch all premium plans and the user's subscription to find which plan they're on @@ -54,7 +57,7 @@ public class UpdatePremiumStorageCommand( if (passwordManagerItem == null) { - return new BadRequest("Premium subscription item not found."); + return new Conflict("Premium subscription does not have a Password Manager line item."); } var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); @@ -66,20 +69,20 @@ public class UpdatePremiumStorageCommand( return new BadRequest("Additional storage cannot be negative."); } - var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb); + var maxStorageGb = (short)(baseStorageGb + additionalStorageGb); - if (newTotalStorageGb > 100) + if (maxStorageGb > 100) { return new BadRequest("Maximum storage is 100 GB."); } // Idempotency check: if user already has the requested storage, return success - if (user.MaxStorageGb == newTotalStorageGb) + if (user.MaxStorageGb == maxStorageGb) { return new None(); } - var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb); + var remainingStorage = user.StorageBytesRemaining(maxStorageGb); if (remainingStorage < 0) { return new BadRequest( @@ -124,21 +127,18 @@ public class UpdatePremiumStorageCommand( }); } - // Update subscription with prorations - // Storage is billed annually, so we create prorations and invoice immediately var subscriptionUpdateOptions = new SubscriptionUpdateOptions { Items = subscriptionItemOptions, - ProrationBehavior = Core.Constants.CreateProrations + ProrationBehavior = ProrationBehavior.AlwaysInvoice }; await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions); // Update the user's max storage - user.MaxStorageGb = newTotalStorageGb; + user.MaxStorageGb = maxStorageGb; await userService.SaveUserAsync(user); - // No payment intent needed - the subscription update will automatically create and finalize the invoice return new None(); }); } diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs new file mode 100644 index 0000000000..e7d988a107 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -0,0 +1,42 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IReinstateSubscriptionCommand +{ + Task> Run(ISubscriber subscriber); +} + +public class ReinstateSubscriptionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IReinstateSubscriptionCommand +{ + public Task> Run(ISubscriber subscriber) => HandleAsync(async () => + { + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); + + if (subscription is not + { + Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, + CancelAt: not null + }) + { + return new BadRequest("Subscription is not pending cancellation."); + } + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = false + }); + + return new None(); + }); +} diff --git a/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs b/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs new file mode 100644 index 0000000000..dde005b7bd --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs @@ -0,0 +1,61 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Bit.Core.Utilities; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Models; + +/// +/// The type of discounts Bitwarden supports. +/// +public enum BitwardenDiscountType +{ + [EnumMember(Value = "amount-off")] + AmountOff, + + [EnumMember(Value = "percent-off")] + PercentOff +} + +/// +/// A record representing a discount applied to a Bitwarden subscription. +/// +public record BitwardenDiscount +{ + /// + /// The type of the discount. + /// + [JsonConverter(typeof(EnumMemberJsonConverter))] + public required BitwardenDiscountType Type { get; init; } + + /// + /// The value of the discount. + /// + public required decimal Value { get; init; } + + public static implicit operator BitwardenDiscount(Discount? discount) + { + if (discount is not + { + Coupon.Valid: true + }) + { + return null!; + } + + return discount.Coupon switch + { + { AmountOff: > 0 } => new BitwardenDiscount + { + Type = BitwardenDiscountType.AmountOff, + Value = discount.Coupon.AmountOff.Value + }, + { PercentOff: > 0 } => new BitwardenDiscount + { + Type = BitwardenDiscountType.PercentOff, + Value = discount.Coupon.PercentOff.Value + }, + _ => null! + }; + } +} diff --git a/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs b/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs new file mode 100644 index 0000000000..5643b35cda --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs @@ -0,0 +1,52 @@ +namespace Bit.Core.Billing.Subscriptions.Models; + +public record BitwardenSubscription +{ + /// + /// The status of the subscription. + /// + public required string Status { get; init; } + + /// + /// The subscription's cart, including line items, any discounts, and estimated tax. + /// + public required Cart Cart { get; init; } + + /// + /// The amount of storage available and used for the subscription. + /// Allowed Subscribers: User, Organization + /// + public Storage? Storage { get; init; } + + /// + /// If the subscription is pending cancellation, the date at which the + /// subscription will be canceled. + /// Allowed Statuses: 'trialing', 'active' + /// + public DateTime? CancelAt { get; init; } + + /// + /// The date the subscription was canceled. + /// Allowed Statuses: 'canceled' + /// + public DateTime? Canceled { get; init; } + + /// + /// The date of the next charge for the subscription. + /// Allowed Statuses: 'trialing', 'active' + /// + public DateTime? NextCharge { get; init; } + + /// + /// The date the subscription will be or was suspended due to lack of payment. + /// Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid' + /// + public DateTime? Suspension { get; init; } + + /// + /// The number of days after the subscription goes 'past_due' the subscriber has to resolve their + /// open invoices before the subscription is suspended. + /// Allowed Statuses: 'past_due' + /// + public int? GracePeriod { get; init; } +} diff --git a/src/Core/Billing/Subscriptions/Models/Cart.cs b/src/Core/Billing/Subscriptions/Models/Cart.cs new file mode 100644 index 0000000000..e7c08919d9 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/Cart.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Billing.Subscriptions.Models; + +public record CartItem +{ + /// + /// The client-side translation key for the name of the cart item. + /// + public required string TranslationKey { get; init; } + + /// + /// The quantity of the cart item. + /// + public required long Quantity { get; init; } + + /// + /// The unit-cost of the cart item. + /// + public required decimal Cost { get; init; } + + /// + /// An optional discount applied specifically to this cart item. + /// + public BitwardenDiscount? Discount { get; init; } +} + +public record PasswordManagerCartItems +{ + /// + /// The Password Manager seats in the cart. + /// + public required CartItem Seats { get; init; } + + /// + /// The additional storage in the cart. + /// + public CartItem? AdditionalStorage { get; init; } +} + +public record SecretsManagerCartItems +{ + /// + /// The Secrets Manager seats in the cart. + /// + public required CartItem Seats { get; init; } + + /// + /// The additional service accounts in the cart. + /// + public CartItem? AdditionalServiceAccounts { get; init; } +} + +public record Cart +{ + /// + /// The Password Manager items in the cart. + /// + public required PasswordManagerCartItems PasswordManager { get; init; } + + /// + /// The Secrets Manager items in the cart. + /// + public SecretsManagerCartItems? SecretsManager { get; init; } + + /// + /// The cart's billing cadence. + /// + [JsonConverter(typeof(EnumMemberJsonConverter))] + public PlanCadenceType Cadence { get; init; } + + /// + /// An optional discount applied to the entire cart. + /// + public BitwardenDiscount? Discount { get; init; } + + /// + /// The estimated tax for the cart. + /// + public required decimal EstimatedTax { get; init; } +} diff --git a/src/Core/Billing/Subscriptions/Models/Storage.cs b/src/Core/Billing/Subscriptions/Models/Storage.cs new file mode 100644 index 0000000000..cd26579bee --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/Storage.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using OneOf; + +namespace Bit.Core.Billing.Subscriptions.Models; + +public record Storage +{ + private const double _bytesPerGibibyte = 1073741824D; + + /// + /// The amount of storage the subscriber has available. + /// + public required short Available { get; init; } + + /// + /// The amount of storage the subscriber has used. + /// + public required double Used { get; init; } + + /// + /// The amount of storage the subscriber has used, formatted as a human-readable string. + /// + public required string ReadableUsed { get; init; } + + public static implicit operator Storage(User user) => From(user); + public static implicit operator Storage(Organization organization) => From(organization); + + private static Storage From(OneOf subscriber) + { + var maxStorageGB = subscriber.Match( + user => user.MaxStorageGb, + organization => organization.MaxStorageGb); + + if (maxStorageGB == null) + { + return null!; + } + + var storage = subscriber.Match( + user => user.Storage, + organization => organization.Storage); + + return new Storage + { + Available = maxStorageGB.Value, + Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2), + ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0) + }; + } +} diff --git a/src/Core/Billing/Subscriptions/Models/SubscriberId.cs b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs new file mode 100644 index 0000000000..1ea842b0e6 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs @@ -0,0 +1,43 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Exceptions; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Models; + +using static StripeConstants; + +public record UserId(Guid Value); + +public record OrganizationId(Guid Value); + +public record ProviderId(Guid Value); + +public class SubscriberId : OneOfBase +{ + private SubscriberId(OneOf input) : base(input) { } + + public static implicit operator SubscriberId(UserId value) => new(value); + public static implicit operator SubscriberId(OrganizationId value) => new(value); + public static implicit operator SubscriberId(ProviderId value) => new(value); + + public static implicit operator SubscriberId(Subscription subscription) + { + if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue) + && Guid.TryParse(userIdValue, out var userId)) + { + return new UserId(userId); + } + + if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue) + && Guid.TryParse(organizationIdValue, out var organizationId)) + { + return new OrganizationId(organizationId); + } + + return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) && + Guid.TryParse(providerIdValue, out var providerId) + ? new ProviderId(providerId) + : throw new ConflictException("Subscription does not have a valid subscriber ID"); + } +} diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs new file mode 100644 index 0000000000..cd7fa91fff --- /dev/null +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -0,0 +1,201 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Queries; + +using static StripeConstants; +using static Utilities; + +public interface IGetBitwardenSubscriptionQuery +{ + /// + /// Retrieves detailed subscription information for a user, including subscription status, + /// cart items, discounts, and billing details. + /// + /// The user whose subscription information to retrieve. + /// + /// A containing the subscription details, or null if no + /// subscription is found or the subscription status is not recognized. + /// + /// + /// Currently only supports subscribers. Future versions will support all + /// types (User and Organization). + /// + Task Run(User user); +} + +public class GetBitwardenSubscriptionQuery( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery +{ + public async Task Run(User user) + { + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = + [ + "customer.discount.coupon.applies_to", + "discounts.coupon.applies_to", + "items.data.price.product", + "test_clock" + ] + }); + + var cart = await GetPremiumCartAsync(subscription); + + var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user }; + + switch (subscription.Status) + { + case SubscriptionStatus.Incomplete: + case SubscriptionStatus.IncompleteExpired: + return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 }; + + case SubscriptionStatus.Trialing: + case SubscriptionStatus.Active: + return baseSubscription with + { + NextCharge = subscription.GetCurrentPeriodEnd(), + CancelAt = subscription.CancelAt + }; + + case SubscriptionStatus.PastDue: + case SubscriptionStatus.Unpaid: + var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); + if (suspension == null) + { + return baseSubscription; + } + return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod }; + + case SubscriptionStatus.Canceled: + return baseSubscription with { Canceled = subscription.CanceledAt }; + + default: + { + logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status); + throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance."); + } + } + } + + private async Task GetPremiumCartAsync( + Subscription subscription) + { + var plans = await pricingClient.ListPremiumPlans(); + + var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item => + plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id)); + + if (passwordManagerSeatsItem == null) + { + throw new ConflictException("Premium subscription does not have a Password Manager line item."); + } + + var additionalStorageItem = subscription.Items.FirstOrDefault(item => + plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id)); + + var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription); + + var passwordManagerSeats = new CartItem + { + TranslationKey = "premiumMembership", + Quantity = passwordManagerSeatsItem.Quantity, + Cost = GetCost(passwordManagerSeatsItem), + Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem)) + }; + + var additionalStorage = additionalStorageItem != null + ? new CartItem + { + TranslationKey = "additionalStorageGB", + Quantity = additionalStorageItem.Quantity, + Cost = GetCost(additionalStorageItem), + Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem)) + } + : null; + + var estimatedTax = await EstimateTaxAsync(subscription); + + return new Cart + { + PasswordManager = new PasswordManagerCartItems + { + Seats = passwordManagerSeats, + AdditionalStorage = additionalStorage + }, + Cadence = PlanCadenceType.Annually, + Discount = cartLevelDiscount, + EstimatedTax = estimatedTax + }; + } + + #region Utilities + + private async Task EstimateTaxAsync(Subscription subscription) + { + try + { + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions + { + Customer = subscription.Customer.Id, + Subscription = subscription.Id + }); + + return GetCost(invoice.TotalTaxes); + } + catch (StripeException stripeException) when + (stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone) + { + return 0; + } + } + + private static decimal GetCost(OneOf> value) => + value.Match( + item => (item.Price.UnitAmountDecimal ?? 0) / 100M, + taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M); + + private static (Discount? CartLevel, List ProductLevel) GetStripeDiscounts( + Subscription subscription) + { + var discounts = new List(); + + if (subscription.Customer.Discount.IsValid()) + { + discounts.Add(subscription.Customer.Discount); + } + + discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid())); + + var cartLevel = new List(); + var productLevel = new List(); + + foreach (var discount in discounts) + { + switch (discount) + { + case { Coupon.AppliesTo.Products: null or { Count: 0 } }: + cartLevel.Add(discount); + break; + case { Coupon.AppliesTo.Products.Count: > 0 }: + productLevel.Add(discount); + break; + } + } + + return (cartLevel.FirstOrDefault(), productLevel); + } + + #endregion +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 373107bb66..47e7eb40bd 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -159,8 +159,6 @@ public static class FeatureFlagKeys public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; 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"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; @@ -204,6 +202,7 @@ public static class FeatureFlagKeys public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; + public const string SdkKeyRotation = "pm-30144-sdk-key-rotation"; public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration"; /* Mobile Team */ diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 0783e84cc4..3df438b493 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -3,6 +3,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Core/Entities/PlayItem.cs b/src/Core/Entities/PlayItem.cs new file mode 100644 index 0000000000..cf2f5c946b --- /dev/null +++ b/src/Core/Entities/PlayItem.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Entities; + +/// +/// PlayItem is a join table tracking entities created during automated testing. +/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server +/// that any data created should be associated with the play, and therefore cleaned up with it. +/// +public class PlayItem : ITableObject +{ + public Guid Id { get; set; } + [MaxLength(256)] + public required string PlayId { get; init; } + public Guid? UserId { get; init; } + public Guid? OrganizationId { get; init; } + public DateTime CreationDate { get; init; } + + /// + /// Generates and sets a new COMB GUID for the Id property. + /// + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + + /// + /// Creates a new PlayItem record associated with a User. + /// + /// The user entity created during the play. + /// The play identifier from the x-play-id header. + /// A new PlayItem instance tracking the user. + public static PlayItem Create(User user, string playId) + { + return new PlayItem + { + PlayId = playId, + UserId = user.Id, + CreationDate = DateTime.UtcNow + }; + } + + /// + /// Creates a new PlayItem record associated with an Organization. + /// + /// The organization entity created during the play. + /// The play identifier from the x-play-id header. + /// A new PlayItem instance tracking the organization. + public static PlayItem Create(Organization organization, string playId) + { + return new PlayItem + { + PlayId = playId, + OrganizationId = organization.Id, + CreationDate = DateTime.UtcNow + }; + } +} diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml index 6d3c46ae67..b94bf0dc86 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml @@ -8,8 +8,8 @@ @@ -33,7 +33,7 @@ icon-alt="Group Users Icon" text="You can easily access and share passwords with your team." foot-url-text="Share passwords in Bitwarden" - foot-url="https://bitwarden.com/help/share-to-a-collection/" + foot-url="https://bitwarden.com/help/sharing" /> diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml index 2b2d854134..c223e2f650 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml @@ -8,8 +8,8 @@ @@ -33,7 +33,7 @@ icon-alt="Group Users Icon" text="You can easily share passwords with friends, family, or coworkers." foot-url-text="Share passwords in Bitwarden" - foot-url="https://bitwarden.com/help/share-to-a-collection/" + foot-url="https://bitwarden.com/help/sharing" /> diff --git a/src/Core/Repositories/IPlayItemRepository.cs b/src/Core/Repositories/IPlayItemRepository.cs new file mode 100644 index 0000000000..fb00be2bc1 --- /dev/null +++ b/src/Core/Repositories/IPlayItemRepository.cs @@ -0,0 +1,11 @@ +using Bit.Core.Entities; + +#nullable enable + +namespace Bit.Core.Repositories; + +public interface IPlayItemRepository : IRepository +{ + Task> GetByPlayIdAsync(string playId); + Task DeleteByPlayIdAsync(string playId); +} diff --git a/src/Core/Services/IBraintreeService.cs b/src/Core/Services/IBraintreeService.cs new file mode 100644 index 0000000000..166d285908 --- /dev/null +++ b/src/Core/Services/IBraintreeService.cs @@ -0,0 +1,11 @@ +using Bit.Core.Billing.Subscriptions.Models; +using Stripe; + +namespace Bit.Core.Services; + +public interface IBraintreeService +{ + Task PayInvoice( + SubscriberId subscriberId, + Invoice invoice); +} diff --git a/src/Core/Services/Implementations/BraintreeService.cs b/src/Core/Services/Implementations/BraintreeService.cs new file mode 100644 index 0000000000..e3630ed888 --- /dev/null +++ b/src/Core/Services/Implementations/BraintreeService.cs @@ -0,0 +1,107 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Exceptions; +using Bit.Core.Settings; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Services.Implementations; + +using static StripeConstants; + +public class BraintreeService( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ILogger logger, + IMailService mailService, + IStripeAdapter stripeAdapter) : IBraintreeService +{ + private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support."); + + public async Task PayInvoice( + SubscriberId subscriberId, + Invoice invoice) + { + if (invoice.Customer == null) + { + logger.LogError("Invoice's ({InvoiceID}) `customer` property must be expanded to be paid with Braintree", + invoice.Id); + throw _problemPayingInvoice; + } + + if (!invoice.Customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + logger.LogError( + "Cannot pay invoice ({InvoiceID}) with Braintree for Customer ({CustomerID}) that does not have a Braintree Customer ID", + invoice.Id, invoice.Customer.Id); + throw _problemPayingInvoice; + } + + if (invoice is not + { + AmountDue: > 0, + Status: not InvoiceStatus.Paid, + CollectionMethod: CollectionMethod.ChargeAutomatically + }) + { + logger.LogWarning("Attempted to pay invoice ({InvoiceID}) with Braintree that is not eligible for payment", invoice.Id); + return; + } + + var amount = Math.Round(invoice.AmountDue / 100M, 2); + + var idKey = subscriberId.Match( + _ => "user_id", + _ => "organization_id", + _ => "provider_id"); + + var idValue = subscriberId.Match( + userId => userId.Value, + organizationId => organizationId.Value, + providerId => providerId.Value); + + var request = new TransactionRequest + { + Amount = amount, + CustomerId = braintreeCustomerId, + Options = new TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new TransactionOptionsPayPalRequest + { + CustomField = $"{idKey}:{idValue},region:{globalSettings.BaseServiceUri.CloudRegion}" + } + }, + CustomFields = new Dictionary + { + [idKey] = idValue.ToString(), + ["region"] = globalSettings.BaseServiceUri.CloudRegion + } + }; + + var result = await braintreeGateway.Transaction.SaleAsync(request); + + if (!result.IsSuccess()) + { + if (invoice.AttemptCount < 4) + { + await mailService.SendPaymentFailedAsync(invoice.Customer.Email, amount, true); + } + + return; + } + + await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + [MetadataKeys.BraintreeTransactionId] = result.Target.Id, + [MetadataKeys.PayPalTransactionId] = result.Target.PayPalDetails.AuthorizationId + } + }); + + await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + } +} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 498721238b..64caf1d462 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; @@ -982,6 +984,16 @@ public class UserService : UserManager, IUserService throw new BadRequestException(exceptionMessage); } + // If the license has a Token (claims-based), extract all properties from claims + // Otherwise, fall back to using the properties already on the license object (backward compatibility) + if (claimsPrincipal != null) + { + license.LicenseKey = claimsPrincipal.GetValue(UserLicenseConstants.LicenseKey); + license.Premium = claimsPrincipal.GetValue(UserLicenseConstants.Premium); + license.MaxStorageGb = claimsPrincipal.GetValue(UserLicenseConstants.MaxStorageGb); + license.Expires = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + var dir = $"{_globalSettings.LicenseDirectory}/user"; Directory.CreateDirectory(dir); using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json")); @@ -995,6 +1007,7 @@ public class UserService : UserManager, IUserService await SaveUserAsync(user); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page public async Task AdjustStorageAsync(User user, short storageAdjustmentGb) { if (user == null) @@ -1040,6 +1053,7 @@ public class UserService : UserManager, IUserService await _paymentService.CancelSubscriptionAsync(user, eop); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page public async Task ReinstatePremiumAsync(User user) { await _paymentService.ReinstateSubscriptionAsync(user); diff --git a/src/Core/Services/Play/IPlayIdService.cs b/src/Core/Services/Play/IPlayIdService.cs new file mode 100644 index 0000000000..542de9725f --- /dev/null +++ b/src/Core/Services/Play/IPlayIdService.cs @@ -0,0 +1,23 @@ +namespace Bit.Core.Services; + +/// +/// Service for managing Play identifiers in automated testing infrastructure. +/// A "Play" is a test session that groups entities created during testing to enable cleanup. +/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service, +/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records +/// to bulk delete all entities associated with a PlayId. Only active in Development environments. +/// +public interface IPlayIdService +{ + /// + /// Gets or sets the current Play identifier from the x-play-id request header. + /// + string? PlayId { get; set; } + + /// + /// Checks whether the current request is part of an active Play session. + /// + /// The Play identifier if active, otherwise empty string. + /// True if in a Play session (has PlayId and in Development environment), otherwise false. + bool InPlay(out string playId); +} diff --git a/src/Core/Services/Play/IPlayItemService.cs b/src/Core/Services/Play/IPlayItemService.cs new file mode 100644 index 0000000000..5033aaf626 --- /dev/null +++ b/src/Core/Services/Play/IPlayItemService.cs @@ -0,0 +1,27 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; + +namespace Bit.Core.Services; + +/// +/// Service used to track added users and organizations during a Play session. +/// +public interface IPlayItemService +{ + /// + /// Records a PlayItem entry for the given User created during a Play session. + /// + /// Does nothing if no Play Id is set for this http scope. + /// + /// + /// + Task Record(User user); + /// + /// Records a PlayItem entry for the given Organization created during a Play session. + /// + /// Does nothing if no Play Id is set for this http scope. + /// + /// + /// + Task Record(Organization organization); +} diff --git a/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs b/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs new file mode 100644 index 0000000000..baab44eedb --- /dev/null +++ b/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.Services; + +public class NeverPlayIdServices : IPlayIdService +{ + public string? PlayId + { + get => null; + set { } + } + + public bool InPlay(out string playId) + { + playId = string.Empty; + return false; + } +} diff --git a/src/Core/Services/Play/Implementations/PlayIdService.cs b/src/Core/Services/Play/Implementations/PlayIdService.cs new file mode 100644 index 0000000000..0c64f1dd14 --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayIdService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService +{ + public string? PlayId { get; set; } + public bool InPlay(out string playId) + { + playId = PlayId ?? string.Empty; + return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment(); + } +} diff --git a/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs b/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs new file mode 100644 index 0000000000..30bb30aad2 --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +/// +/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking. +/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices. +/// +/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling +/// singleton services to participate in Play session tracking without violating DI lifetime rules. +/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs). +/// +public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService +{ + private IPlayIdService Current + { + get + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext == null) + { + return new NeverPlayIdServices(); + } + return httpContext.RequestServices.GetRequiredService(); + } + } + + public string? PlayId + { + get => Current.PlayId; + set => Current.PlayId = value; + } + + public bool InPlay(out string playId) + { + if (hostEnvironment.IsDevelopment()) + { + return Current.InPlay(out playId); + } + else + { + playId = string.Empty; + return false; + } + } +} diff --git a/src/Core/Services/Play/Implementations/PlayItemService.cs b/src/Core/Services/Play/Implementations/PlayItemService.cs new file mode 100644 index 0000000000..981445f2ea --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayItemService.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger logger) : IPlayItemService +{ + public async Task Record(User user) + { + if (playIdService.InPlay(out var playId)) + { + logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId); + await playItemRepository.CreateAsync(PlayItem.Create(user, playId)); + } + } + public async Task Record(Organization organization) + { + if (playIdService.InPlay(out var playId)) + { + logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId); + await playItemRepository.CreateAsync(PlayItem.Create(organization, playId)); + } + } +} diff --git a/src/Core/Services/Play/README.md b/src/Core/Services/Play/README.md new file mode 100644 index 0000000000..b04b6a13c7 --- /dev/null +++ b/src/Core/Services/Play/README.md @@ -0,0 +1,27 @@ +# Play Services + +## Overview + +The Play services provide automated testing infrastructure for tracking and cleaning up test data in development +environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to +enable bulk cleanup via the SeederAPI. + +## How It Works + +1. Test client sends `x-play-id` header with a unique Play identifier +2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService` +3. Repositories check `IPlayIdService.InPlay()` when creating entities +4. `IPlayItemService` records PlayItem entries for tracked entities +5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId + +Play services are **only active in Development environments**. + +## Classes + +- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope +- **`IPlayItemService`** - Interface for tracking entities created during a Play session +- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request +- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available +- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via + HttpContext +- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 60a1fda19f..1f4fa6104b 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings public virtual bool EnableCloudCommunication { get; set; } = false; public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } + public virtual bool TestPlayIdTrackingEnabled { get; set; } = false; public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings(); public virtual IBaseServiceUriSettings BaseServiceUri { get; set; } public virtual string DatabaseProvider { get; set; } diff --git a/src/Core/Utilities/EnumMemberJsonConverter.cs b/src/Core/Utilities/EnumMemberJsonConverter.cs new file mode 100644 index 0000000000..63bebf9cca --- /dev/null +++ b/src/Core/Utilities/EnumMemberJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Bit.Core.Utilities; + +/// +/// A custom JSON converter for enum types that respects the when serializing and deserializing. +/// +/// The enum type to convert. Must be a struct and implement Enum. +/// +/// This converter builds lookup dictionaries at initialization to efficiently map between enum values and their +/// string representations. If an enum value has an , the attribute's Value +/// property is used as the JSON string; otherwise, the enum's ToString() value is used. +/// +public class EnumMemberJsonConverter : JsonConverter where T : struct, Enum +{ + private readonly Dictionary _enumToString = new(); + private readonly Dictionary _stringToEnum = new(); + + public EnumMemberJsonConverter() + { + var type = typeof(T); + var values = Enum.GetValues(); + + foreach (var value in values) + { + var fieldInfo = type.GetField(value.ToString()); + var attribute = fieldInfo?.GetCustomAttribute(); + + var stringValue = attribute?.Value ?? value.ToString(); + _enumToString[value] = stringValue; + _stringToEnum[stringValue] = value; + } + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var stringValue = reader.GetString(); + + if (!string.IsNullOrEmpty(stringValue) && _stringToEnum.TryGetValue(stringValue, out var enumValue)) + { + return enumValue; + } + + throw new JsonException($"Unable to convert '{stringValue}' to {typeof(T).Name}"); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => writer.WriteStringValue(_enumToString[value]); +} diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 75301cf08c..d97d65c2ed 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -36,6 +36,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/EventsProcessor/Startup.cs b/src/EventsProcessor/Startup.cs index 888dda43a1..239393a693 100644 --- a/src/EventsProcessor/Startup.cs +++ b/src/EventsProcessor/Startup.cs @@ -30,6 +30,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Add event integration services services.AddDistributedCache(globalSettings); diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj index 455c8b3155..97e9562183 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -3,6 +3,8 @@ bitwarden-Icons false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index bf5ab82166..db49f8c856 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -3,6 +3,8 @@ bitwarden-Identity false + + $(WarningsNotAsErrors);CA1305 diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index fddc77c806..56b4bb0dcf 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Utilities; @@ -8,7 +7,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Api.Response; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -26,8 +24,6 @@ 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!; private SsoConfig? _ssoConfig; @@ -37,15 +33,13 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder ICurrentContext currentContext, IDeviceRepository deviceRepository, IOrganizationUserRepository organizationUserRepository, - ILoginApprovingClientTypes loginApprovingClientTypes, - IFeatureService featureService + ILoginApprovingClientTypes loginApprovingClientTypes ) { _currentContext = currentContext; _deviceRepository = deviceRepository; _organizationUserRepository = organizationUserRepository; _loginApprovingClientTypes = loginApprovingClientTypes; - _featureService = featureService; } public IUserDecryptionOptionsBuilder ForUser(User user) @@ -145,34 +139,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder // 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); - } + var hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); // 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); @@ -186,10 +153,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder encryptedUserKey); return; + /// Determine if the user has manage reset password permission, + /// as post-SSO logic requires it for forcing users with this permission to set a password. 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; diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 5dc443a73c..9d5536fd10 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -49,6 +49,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 96ddc8c7da..434edb7676 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - private readonly ILogger _logger; + protected readonly ILogger _logger; public OrganizationRepository( GlobalSettings globalSettings, diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index e3ee82270f..dcb0dc1306 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index 8feb455feb..d87bdc33a9 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs b/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs new file mode 100644 index 0000000000..1fa8e88ce8 --- /dev/null +++ b/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs @@ -0,0 +1,45 @@ +using System.Data; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Repositories; + +public class PlayItemRepository : Repository, IPlayItemRepository +{ + public PlayItemRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PlayItemRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task> GetByPlayIdAsync(string playId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[PlayItem_ReadByPlayId]", + new { PlayId = playId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task DeleteByPlayIdAsync(string playId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[PlayItem_DeleteByPlayId]", + new { PlayId = playId }, + commandType: CommandType.StoredProcedure); + } + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 89f0bb5806..88410facf5 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - private readonly ILogger _logger; + protected readonly ILogger _logger; public OrganizationRepository( IServiceScopeFactory serviceScopeFactory, diff --git a/src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs new file mode 100644 index 0000000000..91b26e5be4 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs @@ -0,0 +1,46 @@ +using Bit.Infrastructure.EntityFramework.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Bit.Infrastructure.EntityFramework.Configurations; + +public class PlayItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(pd => pd.Id) + .ValueGeneratedNever(); + + builder + .HasIndex(pd => pd.PlayId) + .IsClustered(false); + + builder + .HasIndex(pd => pd.UserId) + .IsClustered(false); + + builder + .HasIndex(pd => pd.OrganizationId) + .IsClustered(false); + + builder + .HasOne(pd => pd.User) + .WithMany() + .HasForeignKey(pd => pd.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne(pd => pd.Organization) + .WithMany() + .HasForeignKey(pd => pd.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .ToTable(nameof(PlayItem)) + .HasCheckConstraint( + "CK_PlayItem_UserOrOrganization", + "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)" + ); + } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 3c35df2a82..320cb9436d 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj index 9814eef2aa..180bcd7705 100644 --- a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj +++ b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj @@ -1,4 +1,9 @@ + + + $(WarningsNotAsErrors);CA1304;CA1305 + + diff --git a/src/Infrastructure.EntityFramework/Models/PlayItem.cs b/src/Infrastructure.EntityFramework/Models/PlayItem.cs new file mode 100644 index 0000000000..3aafebd555 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Models/PlayItem.cs @@ -0,0 +1,19 @@ +#nullable enable + +using AutoMapper; + +namespace Bit.Infrastructure.EntityFramework.Models; + +public class PlayItem : Core.Entities.PlayItem +{ + public virtual User? User { get; set; } + public virtual AdminConsole.Models.Organization? Organization { get; set; } +} + +public class PlayItemMapperProfile : Profile +{ + public PlayItemMapperProfile() + { + CreateMap().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index b748a26db2..7b67a63912 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -57,6 +57,7 @@ public class DatabaseContext : DbContext public DbSet OrganizationApiKeys { get; set; } public DbSet OrganizationSponsorships { get; set; } public DbSet OrganizationConnections { get; set; } + public DbSet PlayItem { get; set; } public DbSet OrganizationIntegrations { get; set; } public DbSet OrganizationIntegrationConfigurations { get; set; } public DbSet OrganizationUsers { get; set; } diff --git a/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs b/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs new file mode 100644 index 0000000000..c8cf207b43 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs @@ -0,0 +1,42 @@ +using AutoMapper; +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +#nullable enable + +namespace Bit.Infrastructure.EntityFramework.Repositories; + +public class PlayItemRepository : Repository, IPlayItemRepository +{ + public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem) + { } + + public async Task> GetByPlayIdAsync(string playId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var playItemEntities = await GetDbSet(dbContext) + .Where(pd => pd.PlayId == playId) + .ToListAsync(); + return Mapper.Map>(playItemEntities); + } + } + + public async Task DeleteByPlayIdAsync(string playId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var entities = await GetDbSet(dbContext) + .Where(pd => pd.PlayId == playId) + .ToListAsync(); + + dbContext.PlayItem.RemoveRange(entities); + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs b/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs new file mode 100644 index 0000000000..2611d58bfb --- /dev/null +++ b/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Bit.Core.Repositories; +using Bit.SharedWeb.Play.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.SharedWeb.Play; + +public static class PlayServiceCollectionExtensions +{ + /// + /// Adds PlayId tracking decorators for User and Organization repositories using Dapper implementations. + /// This replaces the standard repository implementations with tracking versions + /// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true. + /// + public static void AddPlayIdTrackingDapperRepositories(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Adds PlayId tracking decorators for User and Organization repositories using EntityFramework implementations. + /// This replaces the standard repository implementations with tracking versions + /// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true. + /// + public static void AddPlayIdTrackingEFRepositories(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs b/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs new file mode 100644 index 0000000000..0d11d9c5eb --- /dev/null +++ b/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs @@ -0,0 +1,32 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// Dapper decorator around the that tracks +/// created Organizations for seeding. +/// +public class DapperTestOrganizationTrackingOrganizationRepository : OrganizationRepository +{ + private readonly IPlayItemService _playItemService; + + public DapperTestOrganizationTrackingOrganizationRepository( + IPlayItemService playItemService, + GlobalSettings globalSettings, + ILogger logger) + : base(globalSettings, logger) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Organization obj) + { + var createdOrganization = await base.CreateAsync(obj); + await _playItemService.Record(createdOrganization); + return createdOrganization; + } +} diff --git a/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs b/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs new file mode 100644 index 0000000000..97954c0348 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs @@ -0,0 +1,33 @@ +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Microsoft.AspNetCore.DataProtection; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// Dapper decorator around the that tracks +/// created Users for seeding. +/// +public class DapperTestUserTrackingUserRepository : UserRepository +{ + private readonly IPlayItemService _playItemService; + + public DapperTestUserTrackingUserRepository( + IPlayItemService playItemService, + GlobalSettings globalSettings, + IDataProtectionProvider dataProtectionProvider) + : base(globalSettings, dataProtectionProvider) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(User user) + { + var createdUser = await base.CreateAsync(user); + + await _playItemService.Record(createdUser); + return createdUser; + } +} diff --git a/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs b/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs new file mode 100644 index 0000000000..fdf0e4d685 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// EntityFramework decorator around the that tracks +/// created Organizations for seeding. +/// +public class EFTestOrganizationTrackingOrganizationRepository : OrganizationRepository +{ + private readonly IPlayItemService _playItemService; + + public EFTestOrganizationTrackingOrganizationRepository( + IPlayItemService playItemService, + IServiceScopeFactory serviceScopeFactory, + IMapper mapper, + ILogger logger) + : base(serviceScopeFactory, mapper, logger) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Core.AdminConsole.Entities.Organization organization) + { + var createdOrganization = await base.CreateAsync(organization); + await _playItemService.Record(createdOrganization); + return createdOrganization; + } +} diff --git a/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs b/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs new file mode 100644 index 0000000000..bafcd17ba0 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// EntityFramework decorator around the that tracks +/// created Users for seeding. +/// +public class EFTestUserTrackingUserRepository : UserRepository +{ + private readonly IPlayItemService _playItemService; + + public EFTestUserTrackingUserRepository( + IPlayItemService playItemService, + IServiceScopeFactory serviceScopeFactory, + IMapper mapper) + : base(serviceScopeFactory, mapper) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Core.Entities.User user) + { + var createdUser = await base.CreateAsync(user); + await _playItemService.Record(createdUser); + return createdUser; + } +} diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index d8dc61178d..b6036845b0 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1304 + + diff --git a/src/SharedWeb/Utilities/PlayIdMiddleware.cs b/src/SharedWeb/Utilities/PlayIdMiddleware.cs new file mode 100644 index 0000000000..c00ab2b657 --- /dev/null +++ b/src/SharedWeb/Utilities/PlayIdMiddleware.cs @@ -0,0 +1,41 @@ +using Bit.Core.Services; +using Microsoft.AspNetCore.Http; + +namespace Bit.SharedWeb.Utilities; + +/// +/// Middleware to extract the x-play-id header and set it in the PlayIdService. +/// +/// PlayId is used in testing infrastructure to track data created during automated testing and fa cilitate cleanup. +/// +/// +public sealed class PlayIdMiddleware(RequestDelegate next) +{ + private const int MaxPlayIdLength = 256; + + public async Task Invoke(HttpContext context, PlayIdService playIdService) + { + if (context.Request.Headers.TryGetValue("x-play-id", out var playId)) + { + var playIdValue = playId.ToString(); + + if (string.IsNullOrWhiteSpace(playIdValue)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { Error = "x-play-id header cannot be empty or whitespace" }); + return; + } + + if (playIdValue.Length > MaxPlayIdLength) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { Error = $"x-play-id header cannot exceed {MaxPlayIdLength} characters" }); + return; + } + + playIdService.PlayId = playIdValue; + } + + await next(context); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 5784c2b4fa..5234a257cf 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -57,6 +57,7 @@ using Bit.Core.Vault; using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; +using Bit.SharedWeb.Play; using DnsClient; using Duende.IdentityModel; using LaunchDarkly.Sdk.Server; @@ -118,6 +119,40 @@ public static class ServiceCollectionExtensions return provider; } + /// + /// Registers test PlayId tracking services for test data management and cleanup. + /// This infrastructure is isolated to test environments and enables tracking of test-generated entities. + /// + public static void AddTestPlayIdTracking(this IServiceCollection services, GlobalSettings globalSettings) + { + if (globalSettings.TestPlayIdTrackingEnabled) + { + var (provider, _) = GetDatabaseProvider(globalSettings); + + // Include PlayIdService for tracking Play Ids in repositories + // We need the http context accessor to use the Singleton version, which pulls from the scoped version + services.AddHttpContextAccessor(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + // Replace standard repositories with PlayId tracking decorators + if (provider == SupportedDatabaseProviders.SqlServer) + { + services.AddPlayIdTrackingDapperRepositories(); + } + else + { + services.AddPlayIdTrackingEFRepositories(); + } + } + else + { + services.AddSingleton(); + } + } + public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); @@ -523,6 +558,10 @@ public static class ServiceCollectionExtensions IWebHostEnvironment env, GlobalSettings globalSettings) { app.UseMiddleware(); + if (globalSettings.TestPlayIdTrackingEnabled) + { + app.UseMiddleware(); + } } public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings) diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql b/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql new file mode 100644 index 0000000000..cf75d03d10 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql @@ -0,0 +1,27 @@ +CREATE PROCEDURE [dbo].[PlayItem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @PlayId NVARCHAR(256), + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PlayItem] + ( + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @PlayId, + @UserId, + @OrganizationId, + @CreationDate + ) +END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql b/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql new file mode 100644 index 0000000000..5e2a102e40 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[PlayItem_DeleteByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql b/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql new file mode 100644 index 0000000000..29592c527b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[PlayItem_ReadByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END diff --git a/src/Sql/dbo/Tables/PlayItem.sql b/src/Sql/dbo/Tables/PlayItem.sql new file mode 100644 index 0000000000..fbf3d2e0dd --- /dev/null +++ b/src/Sql/dbo/Tables/PlayItem.sql @@ -0,0 +1,23 @@ +CREATE TABLE [dbo].[PlayItem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [PlayId] NVARCHAR (256) NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL)) +); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId] + ON [dbo].[PlayItem]([PlayId] ASC); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId] + ON [dbo].[PlayItem]([UserId] ASC); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId] + ON [dbo].[PlayItem]([OrganizationId] ASC); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs index 6645f29eae..3ea7d9ff5a 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs @@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -14,8 +13,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -32,12 +29,6 @@ public class OrganizationUserControllerBulkRevokeTests : IClassFixture(featureService => - { - featureService - .IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.IntegrationTest/Api.IntegrationTest.csproj b/test/Api.IntegrationTest/Api.IntegrationTest.csproj index a9d7fd502e..153803ef21 100644 --- a/test/Api.IntegrationTest/Api.IntegrationTest.csproj +++ b/test/Api.IntegrationTest/Api.IntegrationTest.csproj @@ -1,6 +1,8 @@ false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index fb75246d4f..da9cdcff06 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1304 diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index 653785b143..5b14608fc0 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -3,6 +3,8 @@ using Bit.Api.Billing.Models.Requests.Storage; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; @@ -29,9 +31,11 @@ public class AccountBillingVNextControllerTests _sut = new AccountBillingVNextController( Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For(), _getUserLicenseQuery, + Substitute.For(), Substitute.For(), _updatePremiumStorageCommand, _upgradePremiumToOrganizationCommand); @@ -63,7 +67,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -83,7 +87,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -103,7 +107,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -123,7 +127,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -143,7 +147,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -163,7 +167,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -182,7 +186,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -201,7 +205,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -220,7 +224,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -239,7 +243,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index e1b37a9827..295f6bc950 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -26,6 +26,7 @@ public class SutProvider : ISutProvider public TSut Sut { get; private set; } public Type SutType => typeof(TSut); + public IFixture Fixture => _fixture; public SutProvider() : this(new Fixture()) { } @@ -65,6 +66,19 @@ public class SutProvider : ISutProvider return this; } + /// + /// Creates and registers a dependency to be injected when the sut is created. + /// + /// The Dependency type to create + /// The (optional) parameter name to register the dependency under + /// The created dependency value + public TDep CreateDependency(string parameterName = "") + { + var dependency = _fixture.Create(); + SetDependency(dependency, parameterName); + return dependency; + } + /// /// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with /// or automatically with . diff --git a/test/Common/Common.csproj b/test/Common/Common.csproj index 2f11798cef..3d1b6a6c3b 100644 --- a/test/Common/Common.csproj +++ b/test/Common/Common.csproj @@ -2,6 +2,8 @@ false Bit.Test.Common + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs index 7fcda324d9..aa0c2c80c3 100644 --- a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs +++ b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs @@ -1,7 +1,10 @@ -using System.Text.Json; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Billing.Organizations.Models; using Bit.Test.Common.Helpers; using Xunit; @@ -115,4 +118,105 @@ public class OrganizationTests Assert.True(organization.UseDisableSmAdsForUsers); } + + [Fact] + public void UpdateFromLicense_AppliesAllLicenseProperties() + { + // This test ensures that when a new property is added to OrganizationLicense, + // it is also applied to the Organization in UpdateFromLicense(). + // This is the fourth step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Define properties that don't need to be applied to Organization + var excludedProperties = new HashSet + { + // Internal/computed properties + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not applied to org + "Signature", // Signature-related, not applied to org + "Token", // The JWT itself, not applied to org + "Version", // License version, not stored on org + + // Properties intentionally excluded from UpdateFromLicense + "Id", // Self-hosted org has its own unique Guid + "MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense) + + // Properties not stored on Organization model + "LicenseType", // Not a property on Organization + "InstallationId", // Not a property on Organization + "Issued", // Not a property on Organization + "Refresh", // Not a property on Organization + "ExpirationWithoutGracePeriod", // Not a property on Organization + "Trial", // Not a property on Organization + "Expires", // Mapped to ExpirationDate on Organization (different name) + + // Deprecated properties not applied + "LimitCollectionCreationDeletion", // Deprecated, not applied + "AllowAdminAccessToAllCollectionItems", // Deprecated, not applied + }; + + // 3. Get properties that should be applied + var propertiesThatShouldBeApplied = licenseProperties + .Except(excludedProperties) + .ToHashSet(); + + // 4. Read Organization.UpdateFromLicense source code + var organizationSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs"); + var sourceCode = File.ReadAllText(organizationSourcePath); + + // 5. Find all property assignments in UpdateFromLicense method + // Pattern matches: PropertyName = license.PropertyName + // This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires" + var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)"; + var matches = Regex.Matches(sourceCode, assignmentPattern); + + var appliedProperties = new HashSet(); + foreach (Match match in matches) + { + // Get the license property name (right side of assignment) + var licensePropertyName = match.Groups[2].Value; + appliedProperties.Add(licensePropertyName); + } + + // Special case: Expires is mapped to ExpirationDate + if (appliedProperties.Contains("Expires")) + { + appliedProperties.Add("Expires"); // Already added, but being explicit + } + + // 6. Find missing applications + var missingApplications = propertiesThatShouldBeApplied + .Except(appliedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance + var errorMessage = ""; + if (missingApplications.Any()) + { + errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n"; + errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n"; + foreach (var prop in missingApplications) + { + errorMessage += $" {prop} = license.{prop};\n"; + } + errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly."; + } + + // 8. Assert - if this fails, the error message guides the developer to add the application + Assert.True( + !missingApplications.Any(), + $"\n{errorMessage}"); + } } diff --git a/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs new file mode 100644 index 0000000000..df0e7133ad --- /dev/null +++ b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Organizations.Models; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses; + +public class LicenseConstantsTests +{ + [Fact] + public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty() + { + // This test ensures that when a new property is added to OrganizationLicense, + // a corresponding constant is added to OrganizationLicenseConstants. + // This is the first step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Get all constants from OrganizationLicenseConstants + var constants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 3. Define properties that don't need constants (internal/computed/non-claims properties) + var excludedProperties = new HashSet + { + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not in claims system + "Signature", // Signature-related, not in claims system + "Token", // The JWT itself, not a claim within the token + "Version" // Not in claims system (only in deprecated property-based licenses) + }; + + // 4. Find license properties without corresponding constants + var propertiesWithoutConstants = licenseProperties + .Except(constants) + .Except(excludedProperties) + .OrderBy(p => p) + .ToList(); + + // 5. Build error message with guidance + var errorMessage = ""; + if (propertiesWithoutConstants.Any()) + { + errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n"; + errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n"; + foreach (var prop in propertiesWithoutConstants) + { + errorMessage += $" public const string {prop} = nameof({prop});\n"; + } + } + + // 6. Assert - if this fails, the error message guides the developer to add the constant + Assert.True( + !propertiesWithoutConstants.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs new file mode 100644 index 0000000000..43dc416de1 --- /dev/null +++ b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses.Services.Implementations; + +public class OrganizationLicenseClaimsFactoryTests +{ + [Theory, BitAutoData] + public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization) + { + // This test ensures that when a constant is added to OrganizationLicenseConstants, + // it is also added to the OrganizationLicenseClaimsFactory to generate claims. + // This is the second step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Populate all nullable properties to ensure claims can be generated + // The factory only adds claims for properties that have values + organization.Name = "Test Organization"; + organization.BillingEmail = "billing@test.com"; + organization.BusinessName = "Test Business"; + organization.Plan = "Enterprise"; + organization.LicenseKey = "test-license-key"; + organization.Seats = 100; + organization.MaxCollections = 50; + organization.MaxStorageGb = 10; + organization.SmSeats = 25; + organization.SmServiceAccounts = 10; + organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired + + // Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims + // ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions + var licenseContext = new LicenseContext + { + InstallationId = Guid.NewGuid(), + SubscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(null!) + { + TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past + PeriodStartDate = DateTime.UtcNow, + PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days) + Status = "active" + } + } + }; + + // 2. Generate claims + var factory = new OrganizationLicenseClaimsFactory(); + var claims = await factory.GenerateClaims(organization, licenseContext); + + // 3. Get all constants from OrganizationLicenseConstants + var allConstants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 4. Get claim types from generated claims + var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet(); + + // 5. Find constants that don't have corresponding claims + var constantsWithoutClaims = allConstants + .Except(generatedClaimTypes) + .OrderBy(c => c) + .ToList(); + + // 6. Build error message with guidance + var errorMessage = ""; + if (constantsWithoutClaims.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n"; + errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}")); + errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n"; + foreach (var constant in constantsWithoutClaims) + { + errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n"; + } + errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally."; + } + + // 7. Assert - if this fails, the error message guides the developer to add claim generation + Assert.True( + !constantsWithoutClaims.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs index 8befbb000d..46c418e913 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs @@ -1,9 +1,14 @@ -using System.Security.Claims; +using System.Reflection; +using System.Security.Claims; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; using Bit.Core.Settings; @@ -99,6 +104,320 @@ public class UpdateOrganizationLicenseCommandTests } } + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup license for CanUse validation + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + license.Token = "test-token"; // Indicates this is a claims-based license + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Create a ClaimsPrincipal with all organization license claims + var claims = new List + { + new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), + new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()), + new(OrganizationLicenseConstants.Name, "Test Organization"), + new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"), + new(OrganizationLicenseConstants.BusinessName, "Test Business"), + new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()), + new(OrganizationLicenseConstants.Seats, "100"), + new(OrganizationLicenseConstants.MaxCollections, "50"), + new(OrganizationLicenseConstants.UsePolicies, "true"), + new(OrganizationLicenseConstants.UseSso, "true"), + new(OrganizationLicenseConstants.UseKeyConnector, "true"), + new(OrganizationLicenseConstants.UseScim, "true"), + new(OrganizationLicenseConstants.UseGroups, "true"), + new(OrganizationLicenseConstants.UseDirectory, "true"), + new(OrganizationLicenseConstants.UseEvents, "true"), + new(OrganizationLicenseConstants.UseTotp, "true"), + new(OrganizationLicenseConstants.Use2fa, "true"), + new(OrganizationLicenseConstants.UseApi, "true"), + new(OrganizationLicenseConstants.UseResetPassword, "true"), + new(OrganizationLicenseConstants.Plan, "Enterprise"), + new(OrganizationLicenseConstants.SelfHost, "true"), + new(OrganizationLicenseConstants.UsersGetPremium, "true"), + new(OrganizationLicenseConstants.UseCustomPermissions, "true"), + new(OrganizationLicenseConstants.Enabled, "true"), + new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")), + new(OrganizationLicenseConstants.LicenseKey, "test-license-key"), + new(OrganizationLicenseConstants.UsePasswordManager, "true"), + new(OrganizationLicenseConstants.UseSecretsManager, "true"), + new(OrganizationLicenseConstants.SmSeats, "25"), + new(OrganizationLicenseConstants.SmServiceAccounts, "10"), + new(OrganizationLicenseConstants.UseRiskInsights, "true"), + new(OrganizationLicenseConstants.UseOrganizationDomains, "true"), + new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"), + new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"), + new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"), + new(OrganizationLicenseConstants.UsePhishingBlocker, "true"), + new(OrganizationLicenseConstants.MaxStorageGb, "5"), + new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")), + new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")), + new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")), + new(OrganizationLicenseConstants.Trial, "false"), + new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"), + new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true") + }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + // Setup selfHostedOrg for CanUseLicense validation + selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license + selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license + selfHostedOrg.GroupCount = 1; + selfHostedOrg.UseGroups = true; + selfHostedOrg.UsePolicies = true; + selfHostedOrg.UseSso = true; + selfHostedOrg.UseKeyConnector = true; + selfHostedOrg.UseScim = true; + selfHostedOrg.UseCustomPermissions = true; + selfHostedOrg.UseResetPassword = true; + + try + { + await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); + + // Assertion: license file should be written to disk + var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json"); + await using var fs = File.OpenRead(filePath); + var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); + + AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); + + // Assertion: organization should be updated with ALL properties extracted from claims + await sutProvider.GetDependency() + .Received(1) + .ReplaceAndUpdateCacheAsync(Arg.Is(org => + org.Name == "Test Organization" && + org.BillingEmail == "billing@test.com" && + org.BusinessName == "Test Business" && + org.PlanType == PlanType.EnterpriseAnnually && + org.Seats == 100 && + org.MaxCollections == 50 && + org.UsePolicies == true && + org.UseSso == true && + org.UseKeyConnector == true && + org.UseScim == true && + org.UseGroups == true && + org.UseDirectory == true && + org.UseEvents == true && + org.UseTotp == true && + org.Use2fa == true && + org.UseApi == true && + org.UseResetPassword == true && + org.Plan == "Enterprise" && + org.SelfHost == true && + org.UsersGetPremium == true && + org.UseCustomPermissions == true && + org.Enabled == true && + org.LicenseKey == "test-license-key" && + org.UsePasswordManager == true && + org.UseSecretsManager == true && + org.SmSeats == 25 && + org.SmServiceAccounts == 10 && + org.UseRiskInsights == true && + org.UseOrganizationDomains == true && + org.UseAdminSponsoredFamilies == true && + org.UseAutomaticUserConfirmation == true && + org.UseDisableSmAdsForUsers == true && + org.UsePhishingBlocker == true)); + } + finally + { + // Clean up temporary directory + if (Directory.Exists(OrganizationLicenseDirectory.Value)) + { + Directory.Delete(OrganizationLicenseDirectory.Value, true); + } + } + } + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup license for CanUse validation + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.LicenseType = LicenseType.Organization; + license.Token = "test-token"; // Indicates this is a claims-based license + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Create a ClaimsPrincipal with WRONG installation ID + var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id + var claims = new List + { + new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), + new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()), + new(OrganizationLicenseConstants.Enabled, "true"), + new(OrganizationLicenseConstants.SelfHost, "true") + }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); + + Assert.Contains("The installation ID does not match the current installation.", exception.Message); + + // Verify organization was NOT saved + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup legacy license (no Token, no claims) + license.Token = null; // Legacy license + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-2); + license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + license.SelfHost = true; + + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns((ClaimsPrincipal)null); // No claims for legacy license + + // Passing values for SelfHostedOrganizationDetails.CanUseLicense + license.Seats = null; + license.MaxCollections = null; + license.UseGroups = true; + license.UsePolicies = true; + license.UseSso = true; + license.UseKeyConnector = true; + license.UseScim = true; + license.UseCustomPermissions = true; + license.UseResetPassword = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); + + Assert.Contains("The license has expired.", exception.Message); + + // Verify organization was NOT saved + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Fact] + public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided() + { + // This test ensures that when new properties are added to OrganizationLicense, + // they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand. + // If a new constant is added to OrganizationLicenseConstants but not extracted, + // this test will fail with a clear message showing which properties are missing. + + // 1. Get all OrganizationLicenseConstants + var constantFields = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToList(); + + // 2. Define properties that should be excluded (not claims-based or intentionally not extracted) + var excludedProperties = new HashSet + { + "Version", // Not in claims system (only in deprecated property-based licenses) + "Hash", // Signature-related, not extracted from claims + "Signature", // Signature-related, not extracted from claims + "SignatureBytes", // Computed from Signature, not a claim + "Token", // The JWT itself, not extracted from claims + "Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID + }; + + // 3. Get properties that should be extracted from claims + var propertiesThatShouldBeExtracted = constantFields + .Where(c => !excludedProperties.Contains(c)) + .ToHashSet(); + + // 4. Read UpdateOrganizationLicenseCommand source code + var commandSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", + "src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs"); + var sourceCode = await File.ReadAllTextAsync(commandSourcePath); + + // 5. Find all GetValue calls that extract properties from claims + // Pattern matches: license.PropertyName = claimsPrincipal.GetValue(OrganizationLicenseConstants.PropertyName) + var extractedProperties = new HashSet(); + var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)"; + var matches = Regex.Matches(sourceCode, getValuePattern); + + foreach (Match match in matches) + { + extractedProperties.Add(match.Groups[1].Value); + } + + // 6. Find missing extractions + var missingExtractions = propertiesThatShouldBeExtracted + .Except(extractedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance if there are missing extractions + var errorMessage = ""; + if (missingExtractions.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n"; + errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n"; + foreach (var prop in missingExtractions) + { + errorMessage += $" license.{prop} = claimsPrincipal.GetValue(OrganizationLicenseConstants.{prop});\n"; + } + } + + // 8. Assert - if this fails, the error message guides the developer to add the extraction + // Note: We don't check for "extra extractions" because that would be a compile error + // (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist) + Assert.True( + !missingExtractions.Any(), + $"\n{errorMessage}"); + } + // Wrapper to compare 2 objects that are different types private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings) { diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index b58b5cd250..55eb69cc64 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Platform.Push; using Bit.Core.Services; @@ -29,6 +30,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands; public class CreatePremiumCloudHostedSubscriptionCommandTests { private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IGlobalSettings _globalSettings = Substitute.For(); private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); @@ -59,6 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _command = new CreatePremiumCloudHostedSubscriptionCommand( _braintreeGateway, + _braintreeService, _globalSettings, _setupIntentCache, _stripeAdapter, @@ -235,11 +238,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; + mockSubscription.LatestInvoiceId = "in_123"; var mockInvoice = Substitute.For(); @@ -258,6 +265,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any()); await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => opts.AutoAdvance == false)); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); } @@ -456,11 +466,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "incomplete"; + mockSubscription.LatestInvoiceId = "in_123"; mockSubscription.Items = new StripeList { Data = @@ -487,6 +501,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); Assert.True(user.Premium); Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => opts.AutoAdvance == false)); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } [Theory, BitAutoData] @@ -559,11 +576,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; // PayPal + active doesn't match pattern + mockSubscription.LatestInvoiceId = "in_123"; mockSubscription.Items = new StripeList { Data = @@ -590,6 +611,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); Assert.False(user.Premium); Assert.Null(user.PremiumExpirationDate); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => opts.AutoAdvance == false)); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs index 7e3ea562d6..7b9b68c757 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs @@ -18,13 +18,11 @@ public class UpdatePremiumStorageCommandTests private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly PremiumPlan _premiumPlan; private readonly UpdatePremiumStorageCommand _command; public UpdatePremiumStorageCommandTests() { - // Setup default premium plan with standard pricing - _premiumPlan = new PremiumPlan + var premiumPlan = new PremiumPlan { Name = "Premium", Available = true, @@ -32,7 +30,7 @@ public class UpdatePremiumStorageCommandTests Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 }, Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 } }; - _pricingClient.ListPremiumPlans().Returns(new List { _premiumPlan }); + _pricingClient.ListPremiumPlans().Returns([premiumPlan]); _command = new UpdatePremiumStorageCommand( _stripeAdapter, @@ -43,18 +41,19 @@ public class UpdatePremiumStorageCommandTests private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null) { - var items = new List(); - - // Always add the seat item - items.Add(new SubscriptionItem + var items = new List { - Id = "si_seat", - Price = new Price { Id = "price_premium" }, - Quantity = 1 - }); + // Always add the seat item + new() + { + Id = "si_seat", + Price = new Price { Id = "price_premium" }, + Quantity = 1 + } + }; // Add storage item if quantity is provided - if (storageQuantity.HasValue && storageQuantity.Value > 0) + if (storageQuantity is > 0) { items.Add(new SubscriptionItem { @@ -142,7 +141,7 @@ public class UpdatePremiumStorageCommandTests // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; - Assert.Equal("No access to storage.", badRequest.Response); + Assert.Equal("User has no access to storage.", badRequest.Response); } [Theory, BitAutoData] @@ -216,7 +215,7 @@ public class UpdatePremiumStorageCommandTests opts.Items.Count == 1 && opts.Items[0].Id == "si_storage" && opts.Items[0].Quantity == 9 && - opts.ProrationBehavior == "create_prorations")); + opts.ProrationBehavior == "always_invoice")); // Verify user was saved await _userService.Received(1).SaveUserAsync(Arg.Is(u => @@ -233,7 +232,7 @@ public class UpdatePremiumStorageCommandTests user.Storage = 500L * 1024 * 1024; user.GatewaySubscriptionId = "sub_123"; - var subscription = CreateMockSubscription("sub_123", null); + var subscription = CreateMockSubscription("sub_123"); _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); // Act diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs new file mode 100644 index 0000000000..a12a0e4cb0 --- /dev/null +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -0,0 +1,607 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Billing.Subscriptions.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Subscriptions.Queries; + +using static StripeConstants; + +public class GetBitwardenSubscriptionQueryTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly GetBitwardenSubscriptionQuery _query; + + public GetBitwardenSubscriptionQueryTests() + { + _query = new GetBitwardenSubscriptionQuery( + _logger, + _pricingClient, + _stripeAdapter); + } + + [Fact] + public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Incomplete); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Incomplete, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(subscription.Created.AddHours(23), result.Suspension); + Assert.Equal(1, result.GracePeriod); + Assert.Null(result.NextCharge); + Assert.Null(result.CancelAt); + } + + [Fact] + public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(subscription.Created.AddHours(23), result.Suspension); + Assert.Equal(1, result.GracePeriod); + } + + [Fact] + public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Trialing); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Trialing, result.Status); + Assert.NotNull(result.NextCharge); + Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Active, result.Status); + Assert.NotNull(result.NextCharge); + Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt() + { + var user = CreateUser(); + var cancelAt = DateTime.UtcNow.AddMonths(1); + var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Active, result.Status); + Assert.Equal(cancelAt, result.CancelAt); + } + + [Fact] + public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically"); + var premiumPlans = CreatePremiumPlans(); + var openInvoice = CreateInvoice(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([openInvoice]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.PastDue, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension); + Assert.Equal(14, result.GracePeriod); + } + + [Fact] + public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.PastDue); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.PastDue, result.Status); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically"); + var premiumPlans = CreatePremiumPlans(); + var openInvoice = CreateInvoice(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([openInvoice]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Unpaid, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(14, result.GracePeriod); + } + + [Fact] + public async Task Run_CanceledStatus_ReturnsCanceledDate() + { + var user = CreateUser(); + var canceledAt = DateTime.UtcNow.AddDays(-5); + var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Canceled, result.Status); + Assert.Equal(canceledAt, result.Canceled); + Assert.Null(result.Suspension); + Assert.Null(result.NextCharge); + } + + [Fact] + public async Task Run_UnmanagedStatus_ThrowsConflictException() + { + var user = CreateUser(); + var subscription = CreateSubscription("unmanaged_status"); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + + [Fact] + public async Task Run_WithAdditionalStorage_IncludesStorageInCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage); + Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey); + Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity); + Assert.NotNull(result.Storage); + } + + [Fact] + public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Null(result.Cart.PasswordManager.AdditionalStorage); + Assert.NotNull(result.Storage); + } + + [Fact] + public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + subscription.Customer.Discount = CreateDiscount(discountType: "cart"); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.Discount); + Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type); + Assert.Equal(20, result.Cart.Discount.Value); + } + + [Fact] + public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat"); + subscription.Discounts = [productDiscount]; + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.PasswordManager.Seats.Discount); + Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type); + } + + [Fact] + public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage() + { + var user = CreateUser(); + user.MaxStorageGb = null; + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Null(result.Storage); + } + + [Fact] + public async Task Run_CalculatesStorageCorrectly() + { + var user = CreateUser(); + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = 10; + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Storage); + Assert.Equal(10, result.Storage.Available); + Assert.Equal(5.0, result.Storage.Used); + Assert.NotEmpty(result.Storage.ReadableUsed); + } + + [Fact] + public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } }); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(0, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + subscription.Items = new StripeList + { + Data = [] + }; + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + + [Fact] + public async Task Run_IncludesEstimatedTax() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(5.0m, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_SetsCadenceToAnnually() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence); + } + + #region Helper Methods + + private static User CreateUser() + { + return new User + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test123", + MaxStorageGb = 1, + Storage = 1073741824 // 1 GB in bytes + }; + } + + private static Subscription CreateSubscription( + string status, + bool includeStorage = false, + DateTime? cancelAt = null, + DateTime? canceledAt = null, + string collectionMethod = "charge_automatically") + { + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var items = new List + { + new() + { + Id = "si_premium_seat", + Price = new Price + { + Id = "price_premium_seat", + UnitAmountDecimal = 1000, + Product = new Product { Id = "prod_premium_seat" } + }, + Quantity = 1, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = currentPeriodEnd + } + }; + + if (includeStorage) + { + items.Add(new SubscriptionItem + { + Id = "si_storage", + Price = new Price + { + Id = "price_storage", + UnitAmountDecimal = 400, + Product = new Product { Id = "prod_storage" } + }, + Quantity = 2, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = currentPeriodEnd + }); + } + + return new Subscription + { + Id = "sub_test123", + Status = status, + Created = DateTime.UtcNow.AddMonths(-1), + Customer = new Customer + { + Id = "cus_test123", + Discount = null + }, + Items = new StripeList + { + Data = items + }, + CancelAt = cancelAt, + CanceledAt = canceledAt, + CollectionMethod = collectionMethod, + Discounts = [] + }; + } + + private static List CreatePremiumPlans() + { + return + [ + new() + { + Name = "Premium", + Available = true, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_premium_seat", + Price = 10.0m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_storage", + Price = 4.0m, + Provided = 1 + } + } + ]; + } + + private static Invoice CreateInvoice() + { + return new Invoice + { + Id = "in_test123", + Created = DateTime.UtcNow.AddDays(-10), + PeriodEnd = DateTime.UtcNow.AddDays(-5), + Attempted = true, + Status = "open" + }; + } + + private static Invoice CreateInvoicePreview(long totalTax = 0) + { + var taxes = totalTax > 0 + ? new List { new() { Amount = totalTax } } + : new List(); + + return new Invoice + { + Id = "in_preview", + TotalTaxes = taxes + }; + } + + private static Discount CreateDiscount(string discountType = "cart", string? productId = null) + { + var coupon = new Coupon + { + Valid = true, + PercentOff = 20, + AppliesTo = discountType == "product" && productId != null + ? new CouponAppliesTo { Products = [productId] } + : new CouponAppliesTo { Products = [] } + }; + + return new Discount + { + Coupon = coupon + }; + } + + #endregion +} diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index b9e218205c..243e9af806 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -2,6 +2,8 @@ false Bit.Core.Test + + $(WarningsNotAsErrors);CA1304;CA1305 @@ -30,7 +32,7 @@ - + diff --git a/test/Core.Test/Services/PlayIdServiceTests.cs b/test/Core.Test/Services/PlayIdServiceTests.cs new file mode 100644 index 0000000000..7e4977c7bb --- /dev/null +++ b/test/Core.Test/Services/PlayIdServiceTests.cs @@ -0,0 +1,211 @@ +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class PlayIdServiceTests +{ + [Theory] + [BitAutoData] + public void InPlay_WhenPlayIdSetAndDevelopment_ReturnsTrue( + string playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.True(result); + Assert.Equal(playId, resultPlayId); + } + + [Theory] + [BitAutoData] + public void InPlay_WhenPlayIdSetButNotDevelopment_ReturnsFalse( + string playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Production); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.False(result); + Assert.Equal(playId, resultPlayId); + } + + [Theory] + [BitAutoData((string?)null)] + [BitAutoData("")] + public void InPlay_WhenPlayIdNullOrEmptyAndDevelopment_ReturnsFalse( + string? playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.False(result); + Assert.Empty(resultPlayId); + } + + [Theory] + [BitAutoData] + public void PlayId_CanGetAndSet(string playId) + { + var hostEnvironment = Substitute.For(); + var sut = new PlayIdService(hostEnvironment); + + sut.PlayId = playId; + + Assert.Equal(playId, sut.PlayId); + } +} + +[SutProviderCustomize] +public class NeverPlayIdServicesTests +{ + [Fact] + public void InPlay_ReturnsFalse() + { + var sut = new NeverPlayIdServices(); + + var result = sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [InlineData("test-play-id")] + [InlineData(null)] + public void PlayId_SetterDoesNothing_GetterReturnsNull(string? value) + { + var sut = new NeverPlayIdServices(); + + sut.PlayId = value; + + Assert.Null(sut.PlayId); + } +} + +[SutProviderCustomize] +public class PlayIdSingletonServiceTests +{ + public static IEnumerable SutProvider() + { + var sutProvider = new SutProvider(); + var httpContext = sutProvider.CreateDependency(); + var serviceProvider = sutProvider.CreateDependency(); + var hostEnvironment = sutProvider.CreateDependency(); + var playIdService = new PlayIdService(hostEnvironment); + sutProvider.SetDependency(playIdService); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetService().Returns(playIdService); + serviceProvider.GetRequiredService().Returns(playIdService); + sutProvider.CreateDependency().HttpContext.Returns(httpContext); + sutProvider.Create(); + return [[sutProvider]]; + } + + private void PrepHttpContext( + SutProvider sutProvider) + { + var httpContext = sutProvider.CreateDependency(); + var serviceProvider = sutProvider.CreateDependency(); + var PlayIdService = sutProvider.CreateDependency(); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetRequiredService().Returns(PlayIdService); + sutProvider.GetDependency().HttpContext.Returns(httpContext); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenNoHttpContext_ReturnsFalse( + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenNotDevelopment_ReturnsFalse( + SutProvider sutProvider, + string playIdValue) + { + var scopedPlayIdService = sutProvider.GetDependency(); + scopedPlayIdService.PlayId = playIdValue; + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Production); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenDevelopmentAndHttpContextWithPlayId_ReturnsTrue( + SutProvider sutProvider, + string playIdValue) + { + sutProvider.GetDependency().PlayId = playIdValue; + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.True(result); + Assert.Equal(playIdValue, playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_SetterSetsOnScopedService( + SutProvider sutProvider, + string playIdValue) + { + var scopedPlayIdService = sutProvider.GetDependency(); + + sutProvider.Sut.PlayId = playIdValue; + + Assert.Equal(playIdValue, scopedPlayIdService.PlayId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_WhenNoHttpContext_GetterReturnsNull( + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + + var result = sutProvider.Sut.PlayId; + + Assert.Null(result); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_WhenNoHttpContext_SetterDoesNotThrow( + SutProvider sutProvider, + string playIdValue) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + + sutProvider.Sut.PlayId = playIdValue; + } +} diff --git a/test/Core.Test/Services/PlayItemServiceTests.cs b/test/Core.Test/Services/PlayItemServiceTests.cs new file mode 100644 index 0000000000..02e815be59 --- /dev/null +++ b/test/Core.Test/Services/PlayItemServiceTests.cs @@ -0,0 +1,143 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class PlayItemServiceTests +{ + [Theory] + [BitAutoData] + public async Task Record_User_WhenInPlay_RecordsPlayItem( + string playId, + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = playId; + return true; + }); + + await sutProvider.Sut.Record(user); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(pd => + pd.PlayId == playId && + pd.UserId == user.Id && + pd.OrganizationId == null)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString().Contains(user.Id.ToString()) && o.ToString().Contains(playId)), + null, + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_User_WhenNotInPlay_DoesNotRecordPlayItem( + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = null; + return false; + }); + + await sutProvider.Sut.Record(user); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + + sutProvider.GetDependency>() + .DidNotReceive() + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_Organization_WhenInPlay_RecordsPlayItem( + string playId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = playId; + return true; + }); + + await sutProvider.Sut.Record(organization); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(pd => + pd.PlayId == playId && + pd.OrganizationId == organization.Id && + pd.UserId == null)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString().Contains(organization.Id.ToString()) && o.ToString().Contains(playId)), + null, + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_Organization_WhenNotInPlay_DoesNotRecordPlayItem( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = null; + return false; + }); + + await sutProvider.Sut.Record(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + + sutProvider.GetDependency>() + .DidNotReceive() + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } +} diff --git a/test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs b/test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs new file mode 100644 index 0000000000..d0d0d72687 --- /dev/null +++ b/test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs @@ -0,0 +1,219 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EnumMemberJsonConverterTests +{ + [Fact] + public void Serialize_WithEnumMemberAttribute_UsesAttributeValue() + { + // Arrange + var obj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.InProgress + }; + const string expectedJsonString = "{\"Status\":\"in_progress\"}"; + + // Act + var jsonString = JsonSerializer.Serialize(obj); + + // Assert + Assert.Equal(expectedJsonString, jsonString); + } + + [Fact] + public void Serialize_WithoutEnumMemberAttribute_UsesEnumName() + { + // Arrange + var obj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.Pending + }; + const string expectedJsonString = "{\"Status\":\"Pending\"}"; + + // Act + var jsonString = JsonSerializer.Serialize(obj); + + // Assert + Assert.Equal(expectedJsonString, jsonString); + } + + [Fact] + public void Serialize_MultipleValues_SerializesCorrectly() + { + // Arrange + var obj = new EnumConverterTestObjectWithMultiple + { + Status1 = EnumConverterTestStatus.Active, + Status2 = EnumConverterTestStatus.InProgress, + Status3 = EnumConverterTestStatus.Pending + }; + const string expectedJsonString = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}"; + + // Act + var jsonString = JsonSerializer.Serialize(obj); + + // Assert + Assert.Equal(expectedJsonString, jsonString); + } + + [Fact] + public void Deserialize_WithEnumMemberAttribute_ReturnsCorrectEnumValue() + { + // Arrange + const string json = "{\"Status\":\"in_progress\"}"; + + // Act + var obj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status); + } + + [Fact] + public void Deserialize_WithoutEnumMemberAttribute_ReturnsCorrectEnumValue() + { + // Arrange + const string json = "{\"Status\":\"Pending\"}"; + + // Act + var obj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(EnumConverterTestStatus.Pending, obj.Status); + } + + [Fact] + public void Deserialize_MultipleValues_DeserializesCorrectly() + { + // Arrange + const string json = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}"; + + // Act + var obj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(EnumConverterTestStatus.Active, obj.Status1); + Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status2); + Assert.Equal(EnumConverterTestStatus.Pending, obj.Status3); + } + + [Fact] + public void Deserialize_InvalidEnumString_ThrowsJsonException() + { + // Arrange + const string json = "{\"Status\":\"invalid_value\"}"; + + // Act & Assert + var exception = Assert.Throws(() => JsonSerializer.Deserialize(json)); + Assert.Contains("Unable to convert 'invalid_value' to EnumConverterTestStatus", exception.Message); + } + + [Fact] + public void Deserialize_EmptyString_ThrowsJsonException() + { + // Arrange + const string json = "{\"Status\":\"\"}"; + + // Act & Assert + var exception = Assert.Throws(() => JsonSerializer.Deserialize(json)); + Assert.Contains("Unable to convert '' to EnumConverterTestStatus", exception.Message); + } + + [Fact] + public void RoundTrip_WithEnumMemberAttribute_PreservesValue() + { + // Arrange + var originalObj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.Completed + }; + + // Act + var json = JsonSerializer.Serialize(originalObj); + var deserializedObj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(originalObj.Status, deserializedObj.Status); + } + + [Fact] + public void RoundTrip_WithoutEnumMemberAttribute_PreservesValue() + { + // Arrange + var originalObj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.Pending + }; + + // Act + var json = JsonSerializer.Serialize(originalObj); + var deserializedObj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(originalObj.Status, deserializedObj.Status); + } + + [Fact] + public void Serialize_AllEnumValues_ProducesExpectedStrings() + { + // Arrange & Act & Assert + Assert.Equal("\"Pending\"", JsonSerializer.Serialize(EnumConverterTestStatus.Pending, CreateOptions())); + Assert.Equal("\"active\"", JsonSerializer.Serialize(EnumConverterTestStatus.Active, CreateOptions())); + Assert.Equal("\"in_progress\"", JsonSerializer.Serialize(EnumConverterTestStatus.InProgress, CreateOptions())); + Assert.Equal("\"completed\"", JsonSerializer.Serialize(EnumConverterTestStatus.Completed, CreateOptions())); + } + + [Fact] + public void Deserialize_AllEnumValues_ReturnsCorrectEnums() + { + // Arrange & Act & Assert + Assert.Equal(EnumConverterTestStatus.Pending, JsonSerializer.Deserialize("\"Pending\"", CreateOptions())); + Assert.Equal(EnumConverterTestStatus.Active, JsonSerializer.Deserialize("\"active\"", CreateOptions())); + Assert.Equal(EnumConverterTestStatus.InProgress, JsonSerializer.Deserialize("\"in_progress\"", CreateOptions())); + Assert.Equal(EnumConverterTestStatus.Completed, JsonSerializer.Deserialize("\"completed\"", CreateOptions())); + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new EnumMemberJsonConverter()); + return options; + } +} + +public class EnumConverterTestObject +{ + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status { get; set; } +} + +public class EnumConverterTestObjectWithMultiple +{ + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status1 { get; set; } + + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status2 { get; set; } + + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status3 { get; set; } +} + +public enum EnumConverterTestStatus +{ + Pending, // No EnumMemberAttribute + + [EnumMember(Value = "active")] + Active, + + [EnumMember(Value = "in_progress")] + InProgress, + + [EnumMember(Value = "completed")] + Completed +} diff --git a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj index 5c94fad1d1..8a3c0d0fc2 100644 --- a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj +++ b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/test/Identity.Test/Identity.Test.csproj b/test/Identity.Test/Identity.Test.csproj index 496d652b30..8acb0ced92 100644 --- a/test/Identity.Test/Identity.Test.csproj +++ b/test/Identity.Test/Identity.Test.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index 37e88b0ec0..01f693bee9 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Context; @@ -7,7 +6,6 @@ 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; @@ -25,7 +23,6 @@ public class UserDecryptionOptionsBuilderTests private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; private readonly UserDecryptionOptionsBuilder _builder; - private readonly IFeatureService _featureService; public UserDecryptionOptionsBuilderTests() { @@ -33,8 +30,7 @@ public class UserDecryptionOptionsBuilderTests _deviceRepository = Substitute.For(); _organizationUserRepository = Substitute.For(); _loginApprovingClientTypes = Substitute.For(); - _featureService = Substitute.For(); - _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes, _featureService); + _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes); var user = new User(); _builder.ForUser(user); } @@ -227,43 +223,6 @@ public class UserDecryptionOptionsBuilderTests Assert.False(result.TrustedDeviceOption?.HasLoginApprovingDevice); } - /// - /// 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, - [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, - User user) - { - configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; - ssoConfig.Data = configurationData.Serialize(); - ssoConfig.OrganizationId = organization.Id; - _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.ForUser(user).WithSso(ssoConfig).BuildAsync(); - - Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); - } - [Theory] [BitAutoData(OrganizationUserType.Custom)] public async Task Build_WhenManageResetPasswordPermissions_ShouldFetchUserFromRepositoryAndReturnHasManageResetPasswordPermissionTrue( @@ -274,8 +233,6 @@ public class UserDecryptionOptionsBuilderTests [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { - _featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword) - .Returns(true); configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; ssoConfig.Data = configurationData.Serialize(); ssoConfig.OrganizationId = organization.Id; diff --git a/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj b/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj index e63d3d7419..c2e0412752 100644 --- a/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj +++ b/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj @@ -1,6 +1,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs index c458969748..00e3149bbf 100644 --- a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs +++ b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs @@ -128,7 +128,6 @@ public class DatabaseDataAttribute : DataAttribute private void AddDapperServices(IServiceCollection services, Database database) { - services.AddDapperRepositories(SelfHosted); var globalSettings = new GlobalSettings { DatabaseProvider = "sqlServer", @@ -141,6 +140,7 @@ public class DatabaseDataAttribute : DataAttribute UserRequestExpiration = TimeSpan.FromMinutes(15), } }; + services.AddDapperRepositories(SelfHosted); services.AddSingleton(globalSettings); services.AddSingleton(globalSettings); services.AddSingleton(database); @@ -160,7 +160,6 @@ public class DatabaseDataAttribute : DataAttribute private void AddEfServices(IServiceCollection services, Database database) { services.SetupEntityFramework(database.ConnectionString, database.Type); - services.AddPasswordManagerEFRepositories(SelfHosted); var globalSettings = new GlobalSettings { @@ -169,6 +168,7 @@ public class DatabaseDataAttribute : DataAttribute UserRequestExpiration = TimeSpan.FromMinutes(15), }, }; + services.AddPasswordManagerEFRepositories(SelfHosted); services.AddSingleton(globalSettings); services.AddSingleton(globalSettings); diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index a2215e3453..4822df4c77 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -3,6 +3,8 @@ false 6570f288-5c2c-47ad-8978-f3da255079c2 + + $(WarningsNotAsErrors);CA1305 diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 3c0b551908..ba12d1e1f4 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -189,7 +189,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase /// Registers a new user to the Identity Application Factory based on the RegisterFinishRequestModel /// /// RegisterFinishRequestModel needed to seed data to the test user - /// optional parameter that is tracked during the inital steps of registration. + /// optional parameter that is tracked during the initial steps of registration. /// returns the newly created user public async Task RegisterNewIdentityFactoryUserAsync( RegisterFinishRequestModel requestModel, diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 4b42f575a1..dbea807259 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -47,7 +47,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory /// public bool ManagesDatabase { get; set; } = true; - private readonly List> _configureTestServices = new(); + protected readonly List> _configureTestServices = new(); private readonly List> _configureAppConfiguration = new(); public void SubstituteService(Action mockService) diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index a20a14f222..6fd6369f49 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -2,13 +2,15 @@ false + + $(WarningsNotAsErrors);CA1305 - + - + diff --git a/test/SeederApi.IntegrationTest/HttpClientExtensions.cs b/test/SeederApi.IntegrationTest/HttpClientExtensions.cs new file mode 100644 index 0000000000..0fd7934208 --- /dev/null +++ b/test/SeederApi.IntegrationTest/HttpClientExtensions.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Bit.SeederApi.IntegrationTest; + +public static class HttpClientExtensions +{ + /// + /// Sends a POST request with JSON content and attaches the x-play-id header. + /// + /// The type of the value to serialize. + /// The HTTP client. + /// The URI the request is sent to. + /// The value to serialize. + /// The play ID to attach as x-play-id header. + /// Options to control the behavior during serialization. + /// A cancellation token that can be used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task PostAsJsonAsync( + this HttpClient client, + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + TValue value, + string playId, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + + if (string.IsNullOrWhiteSpace(playId)) + { + throw new ArgumentException("Play ID cannot be null or whitespace.", nameof(playId)); + } + + var content = JsonContent.Create(value, mediaType: null, options); + content.Headers.Remove("x-play-id"); + content.Headers.Add("x-play-id", playId); + + return client.PostAsync(requestUri, content, cancellationToken); + } +} diff --git a/test/SeederApi.IntegrationTest/QueryControllerTest.cs b/test/SeederApi.IntegrationTest/QueryControllerTest.cs new file mode 100644 index 0000000000..571181e49f --- /dev/null +++ b/test/SeederApi.IntegrationTest/QueryControllerTest.cs @@ -0,0 +1,75 @@ +using System.Net; +using Bit.SeederApi.Models.Request; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class QueryControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly SeederApiApplicationFactory _factory; + + public QueryControllerTests(SeederApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task QueryEndpoint_WithValidQueryAndArguments_ReturnsOk() + { + var testEmail = $"emergency-test-{Guid.NewGuid()}@bitwarden.com"; + + var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel + { + Template = "EmergencyAccessInviteQuery", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + Assert.NotNull(result); + + var urls = System.Text.Json.JsonSerializer.Deserialize>(result); + Assert.NotNull(urls); + // For a non-existent email, we expect an empty list + Assert.Empty(urls); + } + + [Fact] + public async Task QueryEndpoint_WithInvalidQueryName_ReturnsNotFound() + { + var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel + { + Template = "NonExistentQuery", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" }) + }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task QueryEndpoint_WithMissingRequiredField_ReturnsBadRequest() + { + // EmergencyAccessInviteQuery requires 'email' field + var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel + { + Template = "EmergencyAccessInviteQuery", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" }) + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/test/SeederApi.IntegrationTest/SeedControllerTest.cs b/test/SeederApi.IntegrationTest/SeedControllerTest.cs new file mode 100644 index 0000000000..1d081d019e --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeedControllerTest.cs @@ -0,0 +1,222 @@ +using System.Net; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Models.Response; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class SeedControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly SeederApiApplicationFactory _factory; + + public SeedControllerTests(SeederApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + // Clean up any seeded data after each test + await _client.DeleteAsync("/seed"); + _client.Dispose(); + } + + [Fact] + public async Task SeedEndpoint_WithValidScene_ReturnsOk() + { + var testEmail = $"seed-test-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.NotNull(result.MangleMap); + Assert.Null(result.Result); + } + + [Fact] + public async Task SeedEndpoint_WithInvalidSceneName_ReturnsNotFound() + { + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "NonExistentScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" }) + }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task SeedEndpoint_WithMissingRequiredField_ReturnsBadRequest() + { + // SingleUserScene requires 'email' field + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" }) + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk() + { + var testEmail = $"delete-test-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + + var deleteResponse = await _client.DeleteAsync($"/seed/{playId}"); + deleteResponse.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task DeleteEndpoint_WithInvalidPlayId_ReturnsOk() + { + // DestroyRecipe is idempotent - returns null for non-existent play IDs + var nonExistentPlayId = Guid.NewGuid().ToString(); + var response = await _client.DeleteAsync($"/seed/{nonExistentPlayId}"); + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal($$"""{"playId":"{{nonExistentPlayId}}"}""", content); + } + + [Fact] + public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk() + { + // Create multiple seeds with different play IDs + var playIds = new List(); + for (var i = 0; i < 3; i++) + { + var playId = Guid.NewGuid().ToString(); + playIds.Add(playId); + + var testEmail = $"batch-test-{Guid.NewGuid()}@bitwarden.com"; + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + } + + // Delete them in batch + var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") + { + Content = JsonContent.Create(playIds) + }; + var deleteResponse = await _client.SendAsync(request); + deleteResponse.EnsureSuccessStatusCode(); + + var result = await deleteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal("Batch delete completed successfully", result.Message); + } + + [Fact] + public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk() + { + // DestroyRecipe is idempotent - batch delete succeeds even with non-existent IDs + // Create one valid seed with a play ID + var validPlayId = Guid.NewGuid().ToString(); + var testEmail = $"batch-partial-test-{Guid.NewGuid()}@bitwarden.com"; + + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, validPlayId); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + + // Try to delete with mix of valid and invalid IDs + var playIds = new List { validPlayId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }; + var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") + { + Content = JsonContent.Create(playIds) + }; + var deleteResponse = await _client.SendAsync(request); + + deleteResponse.EnsureSuccessStatusCode(); + var result = await deleteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal("Batch delete completed successfully", result.Message); + } + + [Fact] + public async Task DeleteAllEndpoint_DeletesAllSeededData() + { + // Create multiple seeds + for (var i = 0; i < 2; i++) + { + var playId = Guid.NewGuid().ToString(); + var testEmail = $"deleteall-test-{Guid.NewGuid()}@bitwarden.com"; + + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + seedResponse.EnsureSuccessStatusCode(); + } + + // Delete all + var deleteResponse = await _client.DeleteAsync("/seed"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } + + [Fact] + public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult() + { + var testEmail = $"verify-response-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + response.EnsureSuccessStatusCode(); + var jsonString = await response.Content.ReadAsStringAsync(); + + // Verify the response contains MangleMap and Result fields + Assert.Contains("mangleMap", jsonString, StringComparison.OrdinalIgnoreCase); + Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); + } + + private class BatchDeleteResponse + { + public string? Message { get; set; } + } +} diff --git a/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj b/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj new file mode 100644 index 0000000000..a4709ea58a --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj @@ -0,0 +1,29 @@ + + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + %(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs b/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs new file mode 100644 index 0000000000..6d815b03ea --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs @@ -0,0 +1,18 @@ +using Bit.Core.Services; +using Bit.IntegrationTestCommon; +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.SeederApi.IntegrationTest; + +public class SeederApiApplicationFactory : WebApplicationFactoryBase +{ + public SeederApiApplicationFactory() + { + TestDatabase = new SqliteTestDatabase(); + _configureTestServices.Add(serviceCollection => + { + serviceCollection.AddSingleton(); + serviceCollection.AddHttpContextAccessor(); + }); + } +} diff --git a/test/SharedWeb.Test/PlayIdMiddlewareTests.cs b/test/SharedWeb.Test/PlayIdMiddlewareTests.cs new file mode 100644 index 0000000000..f9ed23c36f --- /dev/null +++ b/test/SharedWeb.Test/PlayIdMiddlewareTests.cs @@ -0,0 +1,102 @@ +using Bit.Core.Services; +using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using NSubstitute; + +namespace SharedWeb.Test; + +public class PlayIdMiddlewareTests +{ + private readonly PlayIdService _playIdService; + private readonly RequestDelegate _next; + private readonly PlayIdMiddleware _middleware; + + public PlayIdMiddlewareTests() + { + var hostEnvironment = Substitute.For(); + hostEnvironment.EnvironmentName.Returns(Environments.Development); + + _playIdService = new PlayIdService(hostEnvironment); + _next = Substitute.For(); + _middleware = new PlayIdMiddleware(_next); + } + + [Fact] + public async Task Invoke_WithValidPlayId_SetsPlayIdAndCallsNext() + { + var context = new DefaultHttpContext(); + context.Request.Headers["x-play-id"] = "test-play-id"; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal("test-play-id", _playIdService.PlayId); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task Invoke_WithoutPlayIdHeader_CallsNext() + { + var context = new DefaultHttpContext(); + + await _middleware.Invoke(context, _playIdService); + + Assert.Null(_playIdService.PlayId); + await _next.Received(1).Invoke(context); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task Invoke_WithEmptyOrWhitespacePlayId_Returns400(string playId) + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["x-play-id"] = playId; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + await _next.DidNotReceive().Invoke(context); + } + + [Fact] + public async Task Invoke_WithPlayIdExceedingMaxLength_Returns400() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var longPlayId = new string('a', 257); // Exceeds 256 character limit + context.Request.Headers["x-play-id"] = longPlayId; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + await _next.DidNotReceive().Invoke(context); + } + + [Fact] + public async Task Invoke_WithPlayIdAtMaxLength_SetsPlayIdAndCallsNext() + { + var context = new DefaultHttpContext(); + var maxLengthPlayId = new string('a', 256); // Exactly 256 characters + context.Request.Headers["x-play-id"] = maxLengthPlayId; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal(maxLengthPlayId, _playIdService.PlayId); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task Invoke_WithSpecialCharactersInPlayId_SetsPlayIdAndCallsNext() + { + var context = new DefaultHttpContext(); + context.Request.Headers["x-play-id"] = "test-play_id.123"; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal("test-play_id.123", _playIdService.PlayId); + await _next.Received(1).Invoke(context); + } +} diff --git a/util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql b/util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql new file mode 100644 index 0000000000..789538cb1c --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql @@ -0,0 +1,90 @@ +-- Create PlayItem table +IF OBJECT_ID('dbo.PlayItem') IS NULL +BEGIN + CREATE TABLE [dbo].[PlayItem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [PlayId] NVARCHAR (256) NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL)) + ); + + CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId] + ON [dbo].[PlayItem]([PlayId] ASC); + + CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId] + ON [dbo].[PlayItem]([UserId] ASC); + + CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId] + ON [dbo].[PlayItem]([OrganizationId] ASC); +END +GO + +-- Create PlayItem_Create stored procedure +CREATE OR ALTER PROCEDURE [dbo].[PlayItem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @PlayId NVARCHAR(256), + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PlayItem] + ( + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @PlayId, + @UserId, + @OrganizationId, + @CreationDate + ) +END +GO + +-- Create PlayItem_ReadByPlayId stored procedure +CREATE OR ALTER PROCEDURE [dbo].[PlayItem_ReadByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END +GO + +-- Create PlayItem_DeleteByPlayId stored procedure +CREATE OR ALTER PROCEDURE [dbo].[PlayItem_DeleteByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END +GO diff --git a/util/Migrator/Migrator.csproj b/util/Migrator/Migrator.csproj index bef6dadfb8..29caf74f39 100644 --- a/util/Migrator/Migrator.csproj +++ b/util/Migrator/Migrator.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs new file mode 100644 index 0000000000..779f8a06f2 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs @@ -0,0 +1,3502 @@ +// +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("20260108193951_CreatePlayItem")] + partial class CreatePlayItem + { + /// + 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("UseAutomaticUserConfirmation") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseDisableSmAdsForUsers") + .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("UsePhishingBlocker") + .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", "UsersGetPremium" }); + + 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("smallint"); + + 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.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.Dirt.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.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + 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.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AuthType") + .HasColumnType("tinyint unsigned"); + + 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(4000) + .HasColumnType("varchar(4000)"); + + 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("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + 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.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (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.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + 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("Archives") + .HasColumnType("longtext"); + + 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.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.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.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + 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.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + 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.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + 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("SecretVersions"); + + 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/20260108193951_CreatePlayItem.cs b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.cs new file mode 100644 index 0000000000..d1b69417d4 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class CreatePlayItem : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayItem", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + PlayId = table.Column(type: "varchar(256)", maxLength: 256, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + UserId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + OrganizationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + CreationDate = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayItem", x => x.Id); + table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_PlayItem_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayItem_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_OrganizationId", + table: "PlayItem", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_PlayId", + table: "PlayItem", + column: "PlayId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_UserId", + table: "PlayItem", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayItem"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index b0b88670a1..dda8d5d179 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -282,71 +282,6 @@ namespace Bit.MySqlMigrations.Migrations 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") @@ -626,8 +561,8 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Type") .HasColumnType("tinyint unsigned"); - b.Property("WaitTimeDays") - .HasColumnType("int"); + b.Property("WaitTimeDays") + .HasColumnType("smallint"); b.HasKey("Id"); @@ -1015,6 +950,71 @@ namespace Bit.MySqlMigrations.Migrations b.ToTable("OrganizationApplication", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.Dirt.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.Dirt.Models.OrganizationReport", b => { b.Property("Id") @@ -1627,6 +1627,42 @@ namespace Bit.MySqlMigrations.Migrations b.ToTable("OrganizationUser", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.Property("Id") @@ -2607,28 +2643,6 @@ namespace Bit.MySqlMigrations.Migrations 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") @@ -2807,6 +2821,28 @@ namespace Bit.MySqlMigrations.Migrations b.Navigation("Organization"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3003,6 +3039,23 @@ namespace Bit.MySqlMigrations.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/MySqlMigrations/MySqlMigrations.csproj b/util/MySqlMigrations/MySqlMigrations.csproj index 641ad90924..b297ea1041 100644 --- a/util/MySqlMigrations/MySqlMigrations.csproj +++ b/util/MySqlMigrations/MySqlMigrations.csproj @@ -2,6 +2,8 @@ 9f1cd3e0-70f2-4921-8068-b2538fd7c3f7 + + $(WarningsNotAsErrors);CA1305 diff --git a/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs new file mode 100644 index 0000000000..0586bd8a23 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs @@ -0,0 +1,3508 @@ +// +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("20260108193909_CreatePlayItem")] + partial class CreatePlayItem + { + /// + 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("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseDisableSmAdsForUsers") + .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("UsePhishingBlocker") + .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", "UsersGetPremium" }); + + 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("smallint"); + + 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.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.Dirt.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.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + 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.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("smallint"); + + 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(4000) + .HasColumnType("character varying(4000)"); + + 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("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + 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.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (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.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + 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("Archives") + .HasColumnType("text"); + + 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.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.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.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + 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.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + 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.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + 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("SecretVersions"); + + 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/20260108193909_CreatePlayItem.cs b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.cs new file mode 100644 index 0000000000..1708ab301e --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class CreatePlayItem : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayItem", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PlayId = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + UserId = table.Column(type: "uuid", nullable: true), + OrganizationId = table.Column(type: "uuid", nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayItem", x => x.Id); + table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_PlayItem_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayItem_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_OrganizationId", + table: "PlayItem", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_PlayId", + table: "PlayItem", + column: "PlayId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_UserId", + table: "PlayItem", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayItem"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 2a0b91e25d..f5419fa319 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -285,71 +285,6 @@ namespace Bit.PostgresMigrations.Migrations 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") @@ -629,8 +564,8 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Type") .HasColumnType("smallint"); - b.Property("WaitTimeDays") - .HasColumnType("integer"); + b.Property("WaitTimeDays") + .HasColumnType("smallint"); b.HasKey("Id"); @@ -1020,6 +955,71 @@ namespace Bit.PostgresMigrations.Migrations b.ToTable("OrganizationApplication", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.Dirt.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.Dirt.Models.OrganizationReport", b => { b.Property("Id") @@ -1632,6 +1632,42 @@ namespace Bit.PostgresMigrations.Migrations b.ToTable("OrganizationUser", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.Property("Id") @@ -2613,28 +2649,6 @@ namespace Bit.PostgresMigrations.Migrations 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") @@ -2813,6 +2827,28 @@ namespace Bit.PostgresMigrations.Migrations b.Navigation("Organization"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3009,6 +3045,23 @@ namespace Bit.PostgresMigrations.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/PostgresMigrations/PostgresMigrations.csproj b/util/PostgresMigrations/PostgresMigrations.csproj index 3496ff67c1..66f3abe769 100644 --- a/util/PostgresMigrations/PostgresMigrations.csproj +++ b/util/PostgresMigrations/PostgresMigrations.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/util/RustSdk/RustSdk.csproj b/util/RustSdk/RustSdk.csproj index ba16a55661..14cc017365 100644 --- a/util/RustSdk/RustSdk.csproj +++ b/util/RustSdk/RustSdk.csproj @@ -10,6 +10,14 @@ + + + + + + + Always true @@ -18,23 +26,36 @@ Always true - runtimes/linux-x64/native/libsdk.dylib + runtimes/linux-x64/native/libsdk.so Always true - runtimes/windows-x64/native/libsdk.dylib + runtimes/windows-x64/native/libsdk.dll - - - + + + + Always + true + runtimes/osx-arm64/native/libsdk.dylib + + + Always + true + runtimes/linux-x64/native/libsdk.so + + + Always + true + runtimes/windows-x64/native/libsdk.dll + diff --git a/util/RustSdk/rust-toolchain.toml b/util/RustSdk/rust-toolchain.toml new file mode 100644 index 0000000000..b8889a3bb3 --- /dev/null +++ b/util/RustSdk/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.87.0" diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 012661501f..3aac87d400 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; -using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Seeder.Factories; diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index b24f8273b9..4fc456981c 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -1,13 +1,59 @@ -using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.Models; +using System.Globalization; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; using Bit.RustSDK; using Microsoft.AspNetCore.Identity; namespace Bit.Seeder.Factories; -public class UserSeeder +public struct UserData { - public static User CreateUser(string email) + public string Email; + public Guid Id; + public string? Key; + public string? PublicKey; + public string? PrivateKey; + public string? ApiKey; + public KdfType Kdf; + public int KdfIterations; +} + +public class UserSeeder(RustSdkService sdkService, IPasswordHasher passwordHasher, MangleId mangleId) +{ + private string MangleEmail(string email) + { + return $"{mangleId}+{email}"; + } + + public User CreateUser(string email, bool emailVerified = false, bool premium = false) + { + email = MangleEmail(email); + var keys = sdkService.GenerateUserKeys(email, "asdfasdfasdf"); + + var user = new User + { + Id = CoreHelpers.GenerateComb(), + Email = email, + EmailVerified = emailVerified, + MasterPassword = null, + SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", + Key = keys.EncryptedUserKey, + PublicKey = keys.PublicKey, + PrivateKey = keys.PrivateKey, + Premium = premium, + ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", + + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5_000, + }; + + user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + + return user; + } + + public static User CreateUserNoMangle(string email) { return new User { @@ -25,28 +71,35 @@ public class UserSeeder }; } - public static (User user, string userKey) CreateSdkUser(IPasswordHasher passwordHasher, string email) + public Dictionary GetMangleMap(User user, UserData expectedUserData) { - var nativeService = RustSdkServiceFactory.CreateSingleton(); - var keys = nativeService.GenerateUserKeys(email, "asdfasdfasdf"); - - var user = new User + var mangleMap = new Dictionary { - Id = Guid.NewGuid(), - Email = email, - MasterPassword = null, - SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", - Key = keys.EncryptedUserKey, - PublicKey = keys.PublicKey, - PrivateKey = keys.PrivateKey, - ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", - - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 5_000, + { expectedUserData.Email, MangleEmail(expectedUserData.Email) }, + { expectedUserData.Id.ToString(), user.Id.ToString() }, + { expectedUserData.Kdf.ToString(), user.Kdf.ToString() }, + { expectedUserData.KdfIterations.ToString(CultureInfo.InvariantCulture), user.KdfIterations.ToString(CultureInfo.InvariantCulture) } }; + if (expectedUserData.Key != null) + { + mangleMap[expectedUserData.Key] = user.Key; + } - user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + if (expectedUserData.PublicKey != null) + { + mangleMap[expectedUserData.PublicKey] = user.PublicKey; + } - return (user, keys.Key); + if (expectedUserData.PrivateKey != null) + { + mangleMap[expectedUserData.PrivateKey] = user.PrivateKey; + } + + if (expectedUserData.ApiKey != null) + { + mangleMap[expectedUserData.ApiKey] = user.ApiKey; + } + + return mangleMap; } } diff --git a/util/Seeder/IQuery.cs b/util/Seeder/IQuery.cs new file mode 100644 index 0000000000..adbcd8e59d --- /dev/null +++ b/util/Seeder/IQuery.cs @@ -0,0 +1,60 @@ +namespace Bit.Seeder; + +/// +/// Base interface for query operations in the seeding system. The base interface should not be used directly, rather use `IQuery<TRequest, TResult>`. +/// +/// +/// Queries are synchronous, read-only operations that retrieve data from the seeding context. +/// Unlike scenes which create data, queries fetch existing data based on request parameters. +/// They follow a type-safe pattern using generics to ensure proper request/response handling +/// while maintaining a common non-generic interface for dynamic invocation. +/// +public interface IQuery +{ + /// + /// Gets the type of request this query expects. + /// + /// The request type that this query can process. + Type GetRequestType(); + + /// + /// Executes the query based on the provided request object. + /// + /// The request object containing parameters for the query operation. + /// The query result data as an object. + object Execute(object request); +} + +/// +/// Generic query interface for synchronous, read-only operations with specific request and result types. +/// +/// The type of request object this query accepts. +/// The type of data this query returns. +/// +/// Use this interface when you need to retrieve existing data from the seeding context based on +/// specific request parameters. Queries are synchronous and do not modify data - they only read +/// and return information. The explicit interface implementations allow dynamic invocation while +/// maintaining type safety in the implementation. +/// +public interface IQuery : IQuery where TRequest : class where TResult : class +{ + /// + /// Executes the query based on the provided strongly-typed request and returns typed result data. + /// + /// The request object containing parameters for the query operation. + /// The typed query result data. + TResult Execute(TRequest request); + + /// + /// Gets the request type for this query. + /// + /// The type of TRequest. + Type IQuery.GetRequestType() => typeof(TRequest); + + /// + /// Adapts the non-generic Execute to the strongly-typed version. + /// + /// The request object to cast and process. + /// The typed result cast to object. + object IQuery.Execute(object request) => Execute((TRequest)request); +} diff --git a/util/Seeder/IScene.cs b/util/Seeder/IScene.cs new file mode 100644 index 0000000000..6f513973ba --- /dev/null +++ b/util/Seeder/IScene.cs @@ -0,0 +1,96 @@ +namespace Bit.Seeder; + +/// +/// Base interface for seeding operations. The base interface should not be used directly, rather use `IScene<Request>`. +/// +/// +/// Scenes are components in the seeding system that create and configure test data. They follow +/// a type-safe pattern using generics to ensure proper request/response handling while maintaining +/// a common non-generic interface for dynamic invocation. +/// +public interface IScene +{ + /// + /// Gets the type of request this scene expects. + /// + /// The request type that this scene can process. + Type GetRequestType(); + + /// + /// Seeds data based on the provided request object. + /// + /// The request object containing parameters for the seeding operation. + /// A scene result containing any returned data, mangle map, and entity tracking information. + Task> SeedAsync(object request); +} + +/// +/// Generic scene interface for seeding operations with a specific request type. Does not return a value beyond tracking entities and a mangle map. +/// +/// The type of request object this scene accepts. +/// +/// Use this interface when your scene needs to process a specific request type but doesn't need to +/// return any data beyond the standard mangle map for ID transformations and entity tracking. +/// The explicit interface implementations allow this scene to be invoked dynamically through the +/// base IScene interface while maintaining type safety in the implementation. +/// +public interface IScene : IScene where TRequest : class +{ + /// + /// Seeds data based on the provided strongly-typed request. + /// + /// The request object containing parameters for the seeding operation. + /// A scene result containing the mangle map and entity tracking information. + Task SeedAsync(TRequest request); + + /// + /// Gets the request type for this scene. + /// + /// The type of TRequest. + Type IScene.GetRequestType() => typeof(TRequest); + + /// + /// Adapts the non-generic SeedAsync to the strongly-typed version. + /// + /// The request object to cast and process. + /// A scene result wrapped as an object result. + async Task> IScene.SeedAsync(object request) + { + var result = await SeedAsync((TRequest)request); + return new SceneResult(mangleMap: result.MangleMap); + } +} + +/// +/// Generic scene interface for seeding operations with a specific request type that returns typed data. +/// +/// The type of request object this scene accepts. Must be a reference type. +/// The type of data this scene returns. Must be a reference type. +/// +/// Use this interface when your scene needs to return specific data that can be used by subsequent +/// scenes or test logic. The result is wrapped in a SceneResult that also includes the mangle map +/// and entity tracking information. The explicit interface implementations allow dynamic invocation +/// while preserving type safety in the implementation. +/// +public interface IScene : IScene where TRequest : class where TResult : class +{ + /// + /// Seeds data based on the provided strongly-typed request and returns typed result data. + /// + /// The request object containing parameters for the seeding operation. + /// A scene result containing the typed result data, mangle map, and entity tracking information. + Task> SeedAsync(TRequest request); + + /// + /// Gets the request type for this scene. + /// + /// The type of TRequest. + Type IScene.GetRequestType() => typeof(TRequest); + + /// + /// Adapts the non-generic SeedAsync to the strongly-typed version. + /// + /// The request object to cast and process. + /// A scene result with the typed result cast to object. + async Task> IScene.SeedAsync(object request) => (SceneResult)await SeedAsync((TRequest)request); +} diff --git a/util/Seeder/MangleId.cs b/util/Seeder/MangleId.cs new file mode 100644 index 0000000000..8cc862c28c --- /dev/null +++ b/util/Seeder/MangleId.cs @@ -0,0 +1,21 @@ +using System.Globalization; + +namespace Bit.Seeder; + +/// +/// Helper for generating unique identifier suffixes to prevent collisions in test data. +/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.) +/// to ensure uniqueness across multiple test runs and parallel test executions. +/// +public class MangleId +{ + public readonly string Value; + + public MangleId() + { + // Generate a short random string (6 char) to use as the mangle ID + Value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture).Substring(0, 8); + } + + public override string ToString() => Value; +} diff --git a/util/Seeder/Queries/EmergencyAccessInviteQuery.cs b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs new file mode 100644 index 0000000000..95d96a9a50 --- /dev/null +++ b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Tokens; +using Bit.Infrastructure.EntityFramework.Repositories; + +namespace Bit.Seeder.Queries; + +/// +/// Retrieves all emergency access invite urls for the provided email. +/// +public class EmergencyAccessInviteQuery( + DatabaseContext db, + IDataProtectorTokenFactory dataProtectorTokenizer) + : IQuery> +{ + public class Request + { + [Required] + public required string Email { get; set; } + } + + public IEnumerable Execute(Request request) + { + var invites = db.EmergencyAccesses + .Where(ea => ea.Email == request.Email).ToList().Select(ea => + { + var token = dataProtectorTokenizer.Protect( + new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1) + ); + return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}"; + }); + + return invites; + } +} diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index 7678c3a9ce..87fcc1967b 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; @@ -12,14 +12,14 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) { var seats = Math.Max(users + 1, 1000); var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); - var ownerUser = UserSeeder.CreateUser($"owner@{domain}"); + var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}"); var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); var additionalUsers = new List(); var additionalOrgUsers = new List(); for (var i = 0; i < users; i++) { - var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}"); + var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}"); additionalUsers.Add(additionalUser); additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); } diff --git a/util/Seeder/SceneResult.cs b/util/Seeder/SceneResult.cs new file mode 100644 index 0000000000..7ac004f55e --- /dev/null +++ b/util/Seeder/SceneResult.cs @@ -0,0 +1,28 @@ +namespace Bit.Seeder; + +/// +/// Helper for exposing a interface with a SeedAsync method. +/// +public class SceneResult(Dictionary mangleMap) + : SceneResult(result: null, mangleMap: mangleMap); + +/// +/// Generic result from executing a Scene. +/// Contains custom scene-specific data and a mangle map that maps magic strings from the +/// request to their mangled (collision-free) values inserted into the database. +/// +/// The type of custom result data returned by the scene. +public class SceneResult(TResult result, Dictionary mangleMap) +{ + public TResult Result { get; init; } = result; + public Dictionary MangleMap { get; init; } = mangleMap; + + public static explicit operator SceneResult(SceneResult v) + { + var result = v.Result; + + return result is null + ? new SceneResult(result: null, mangleMap: v.MangleMap) + : new SceneResult(result: result, mangleMap: v.MangleMap); + } +} diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs new file mode 100644 index 0000000000..df941c7f59 --- /dev/null +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Repositories; +using Bit.Seeder.Factories; + +namespace Bit.Seeder.Scenes; + +/// +/// Creates a single user using the provided account details. +/// +public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene +{ + public class Request + { + [Required] + public required string Email { get; set; } + public bool EmailVerified { get; set; } = false; + public bool Premium { get; set; } = false; + } + + public async Task SeedAsync(Request request) + { + var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium); + + await userRepository.CreateAsync(user); + + return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData + { + Email = request.Email, + Id = user.Id, + Key = user.Key, + PublicKey = user.PublicKey, + PrivateKey = user.PrivateKey, + ApiKey = user.ApiKey, + Kdf = user.Kdf, + KdfIterations = user.KdfIterations, + })); + } +} diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index 4d7fbab767..fd6e26c1ee 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -12,10 +12,6 @@ false - - - - diff --git a/util/SeederApi/Commands/DestroyBatchScenesCommand.cs b/util/SeederApi/Commands/DestroyBatchScenesCommand.cs new file mode 100644 index 0000000000..50f6142a98 --- /dev/null +++ b/util/SeederApi/Commands/DestroyBatchScenesCommand.cs @@ -0,0 +1,36 @@ +using Bit.SeederApi.Commands.Interfaces; + +namespace Bit.SeederApi.Commands; + +public class DestroyBatchScenesCommand( + ILogger logger, + IDestroySceneCommand destroySceneCommand) : IDestroyBatchScenesCommand +{ + public async Task DestroyAsync(IEnumerable playIds) + { + var exceptions = new List(); + + var deleteTasks = playIds.Select(async playId => + { + try + { + await destroySceneCommand.DestroyAsync(playId); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId); + } + }); + + await Task.WhenAll(deleteTasks); + + if (exceptions.Count > 0) + { + throw new AggregateException("One or more errors occurred while deleting seeded data", exceptions); + } + } +} diff --git a/util/SeederApi/Commands/DestroySceneCommand.cs b/util/SeederApi/Commands/DestroySceneCommand.cs new file mode 100644 index 0000000000..0e0f4edd6d --- /dev/null +++ b/util/SeederApi/Commands/DestroySceneCommand.cs @@ -0,0 +1,57 @@ +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.SeederApi.Commands.Interfaces; +using Bit.SeederApi.Services; + +namespace Bit.SeederApi.Commands; + +public class DestroySceneCommand( + DatabaseContext databaseContext, + ILogger logger, + IUserRepository userRepository, + IPlayItemRepository playItemRepository, + IOrganizationRepository organizationRepository) : IDestroySceneCommand +{ + public async Task DestroyAsync(string playId) + { + // Note, delete cascade will remove PlayItem entries + + var playItem = await playItemRepository.GetByPlayIdAsync(playId); + var userIds = playItem.Select(pd => pd.UserId).Distinct().ToList(); + var organizationIds = playItem.Select(pd => pd.OrganizationId).Distinct().ToList(); + + // Delete Users before Organizations to respect foreign key constraints + if (userIds.Count > 0) + { + var users = databaseContext.Users.Where(u => userIds.Contains(u.Id)); + await userRepository.DeleteManyAsync(users); + } + + if (organizationIds.Count > 0) + { + var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id)); + var aggregateException = new AggregateException(); + foreach (var org in organizations) + { + try + { + await organizationRepository.DeleteAsync(org); + } + catch (Exception ex) + { + aggregateException = new AggregateException(aggregateException, ex); + } + } + if (aggregateException.InnerExceptions.Count > 0) + { + throw new SceneExecutionException( + $"One or more errors occurred while deleting organizations for seed ID {playId}", + aggregateException); + } + } + + logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}", playId); + + return new { PlayId = playId }; + } +} diff --git a/util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs b/util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs new file mode 100644 index 0000000000..ce43f26a54 --- /dev/null +++ b/util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.SeederApi.Commands.Interfaces; + +/// +/// Command for destroying multiple scenes in parallel. +/// +public interface IDestroyBatchScenesCommand +{ + /// + /// Destroys multiple scenes by their play IDs in parallel. + /// + /// The list of play IDs to destroy + /// Thrown when one or more scenes fail to destroy + Task DestroyAsync(IEnumerable playIds); +} diff --git a/util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs b/util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs new file mode 100644 index 0000000000..a3b0800bb2 --- /dev/null +++ b/util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs @@ -0,0 +1,15 @@ +namespace Bit.SeederApi.Commands.Interfaces; + +/// +/// Command for destroying data created by a single scene. +/// +public interface IDestroySceneCommand +{ + /// + /// Destroys data created by a scene using the seeded data ID. + /// + /// The ID of the seeded data to destroy + /// The result of the destroy operation + /// Thrown when there's an error destroying the seeded data + Task DestroyAsync(string playId); +} diff --git a/util/SeederApi/Controllers/InfoController.cs b/util/SeederApi/Controllers/InfoController.cs new file mode 100644 index 0000000000..de4a264ddb --- /dev/null +++ b/util/SeederApi/Controllers/InfoController.cs @@ -0,0 +1,20 @@ +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers; + +public class InfoController : Controller +{ + [HttpGet("~/alive")] + [HttpGet("~/now")] + public DateTime GetAlive() + { + return DateTime.UtcNow; + } + + [HttpGet("~/version")] + public JsonResult GetVersion() + { + return Json(AssemblyHelpers.GetVersion()); + } +} diff --git a/util/SeederApi/Controllers/QueryController.cs b/util/SeederApi/Controllers/QueryController.cs new file mode 100644 index 0000000000..22bf84e5b7 --- /dev/null +++ b/util/SeederApi/Controllers/QueryController.cs @@ -0,0 +1,32 @@ +using Bit.SeederApi.Execution; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers; + +[Route("query")] +public class QueryController(ILogger logger, IQueryExecutor queryExecutor) : Controller +{ + [HttpPost] + public IActionResult Query([FromBody] QueryRequestModel request) + { + logger.LogInformation("Executing query: {Query}", request.Template); + + try + { + var result = queryExecutor.Execute(request.Template, request.Arguments); + + return Json(result); + } + catch (QueryNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (QueryExecutionException ex) + { + logger.LogError(ex, "Error executing query: {Query}", request.Template); + return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); + } + } +} diff --git a/util/SeederApi/Controllers/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs new file mode 100644 index 0000000000..44f0dbaf2c --- /dev/null +++ b/util/SeederApi/Controllers/SeedController.cs @@ -0,0 +1,100 @@ +using Bit.SeederApi.Commands.Interfaces; +using Bit.SeederApi.Execution; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Queries.Interfaces; +using Bit.SeederApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers; + +[Route("seed")] +public class SeedController( + ILogger logger, + ISceneExecutor sceneExecutor, + IDestroySceneCommand destroySceneCommand, + IDestroyBatchScenesCommand destroyBatchScenesCommand, + IGetAllPlayIdsQuery getAllPlayIdsQuery) : Controller +{ + [HttpPost] + public async Task SeedAsync([FromBody] SeedRequestModel request) + { + logger.LogInformation("Received seed request with template: {Template}", request.Template); + + try + { + var response = await sceneExecutor.ExecuteAsync(request.Template, request.Arguments); + + return Json(response); + } + catch (SceneNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (SceneExecutionException ex) + { + logger.LogError(ex, "Error executing scene: {Template}", request.Template); + return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); + } + } + + [HttpDelete("batch")] + public async Task DeleteBatchAsync([FromBody] List playIds) + { + logger.LogInformation("Deleting batch of seeded data with IDs: {PlayIds}", string.Join(", ", playIds)); + + try + { + await destroyBatchScenesCommand.DestroyAsync(playIds); + return Ok(new { Message = "Batch delete completed successfully" }); + } + catch (AggregateException ex) + { + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerExceptions.Select(e => e.Message).ToList() + }); + } + } + + [HttpDelete("{playId}")] + public async Task DeleteAsync([FromRoute] string playId) + { + logger.LogInformation("Deleting seeded data with ID: {PlayId}", playId); + + try + { + var result = await destroySceneCommand.DestroyAsync(playId); + + return Json(result); + } + catch (SceneExecutionException ex) + { + logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId); + return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); + } + } + + + [HttpDelete] + public async Task DeleteAllAsync() + { + logger.LogInformation("Deleting all seeded data"); + + var playIds = getAllPlayIdsQuery.GetAllPlayIds(); + + try + { + await destroyBatchScenesCommand.DestroyAsync(playIds); + return NoContent(); + } + catch (AggregateException ex) + { + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerExceptions.Select(e => e.Message).ToList() + }); + } + } +} diff --git a/util/SeederApi/Execution/IQueryExecutor.cs b/util/SeederApi/Execution/IQueryExecutor.cs new file mode 100644 index 0000000000..ebd971bbb7 --- /dev/null +++ b/util/SeederApi/Execution/IQueryExecutor.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Bit.SeederApi.Execution; + +/// +/// Executor for dynamically resolving and executing queries by name. +/// This is an infrastructure component that orchestrates query execution, +/// not a domain-level query. +/// +public interface IQueryExecutor +{ + /// + /// Executes a query with the given query name and arguments. + /// Queries are read-only and do not track entities or create seed IDs. + /// + /// The name of the query (e.g., "EmergencyAccessInviteQuery") + /// Optional JSON arguments to pass to the query's Execute method + /// The result of the query execution + /// Thrown when the query is not found + /// Thrown when there's an error executing the query + object Execute(string queryName, JsonElement? arguments); +} diff --git a/util/SeederApi/Execution/ISceneExecutor.cs b/util/SeederApi/Execution/ISceneExecutor.cs new file mode 100644 index 0000000000..f15909ea79 --- /dev/null +++ b/util/SeederApi/Execution/ISceneExecutor.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Bit.SeederApi.Models.Response; + +namespace Bit.SeederApi.Execution; + +/// +/// Executor for dynamically resolving and executing scenes by template name. +/// This is an infrastructure component that orchestrates scene execution, +/// not a domain-level command. +/// +public interface ISceneExecutor +{ + /// + /// Executes a scene with the given template name and arguments. + /// + /// The name of the scene template (e.g., "SingleUserScene") + /// Optional JSON arguments to pass to the scene's Seed method + /// A scene response model containing the result and mangle map + /// Thrown when the scene template is not found + /// Thrown when there's an error executing the scene + Task ExecuteAsync(string templateName, JsonElement? arguments); +} diff --git a/util/SeederApi/Execution/JsonConfiguration.cs b/util/SeederApi/Execution/JsonConfiguration.cs new file mode 100644 index 0000000000..beef36e62a --- /dev/null +++ b/util/SeederApi/Execution/JsonConfiguration.cs @@ -0,0 +1,19 @@ +using System.Text.Json; + +namespace Bit.SeederApi.Execution; + +/// +/// Provides shared JSON serialization configuration for executors. +/// +internal static class JsonConfiguration +{ + /// + /// Standard JSON serializer options used for deserializing scene and query request models. + /// Uses case-insensitive property matching and camelCase naming policy. + /// + internal static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; +} diff --git a/util/SeederApi/Execution/QueryExecutor.cs b/util/SeederApi/Execution/QueryExecutor.cs new file mode 100644 index 0000000000..5473586c22 --- /dev/null +++ b/util/SeederApi/Execution/QueryExecutor.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Bit.Seeder; +using Bit.SeederApi.Services; + +namespace Bit.SeederApi.Execution; + +public class QueryExecutor( + ILogger logger, + IServiceProvider serviceProvider) : IQueryExecutor +{ + + public object Execute(string queryName, JsonElement? arguments) + { + try + { + var query = serviceProvider.GetKeyedService(queryName) + ?? throw new QueryNotFoundException(queryName); + + var requestType = query.GetRequestType(); + var requestModel = DeserializeRequestModel(queryName, requestType, arguments); + var result = query.Execute(requestModel); + + logger.LogInformation("Successfully executed query: {QueryName}", queryName); + return result; + } + catch (Exception ex) when (ex is not QueryNotFoundException and not QueryExecutionException) + { + logger.LogError(ex, "Unexpected error executing query: {QueryName}", queryName); + throw new QueryExecutionException( + $"An unexpected error occurred while executing query '{queryName}'", + ex.InnerException ?? ex); + } + } + + private object DeserializeRequestModel(string queryName, Type requestType, JsonElement? arguments) + { + if (arguments == null) + { + return CreateDefaultRequestModel(queryName, requestType); + } + + try + { + var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options); + if (requestModel == null) + { + throw new QueryExecutionException( + $"Failed to deserialize request model for query '{queryName}'"); + } + return requestModel; + } + catch (JsonException ex) + { + throw new QueryExecutionException( + $"Failed to deserialize request model for query '{queryName}': {ex.Message}", ex); + } + } + + private object CreateDefaultRequestModel(string queryName, Type requestType) + { + try + { + var requestModel = Activator.CreateInstance(requestType); + if (requestModel == null) + { + throw new QueryExecutionException( + $"Arguments are required for query '{queryName}'"); + } + return requestModel; + } + catch + { + throw new QueryExecutionException( + $"Arguments are required for query '{queryName}'"); + } + } +} diff --git a/util/SeederApi/Execution/SceneExecutor.cs b/util/SeederApi/Execution/SceneExecutor.cs new file mode 100644 index 0000000000..f31dd7d943 --- /dev/null +++ b/util/SeederApi/Execution/SceneExecutor.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Bit.Seeder; +using Bit.SeederApi.Models.Response; +using Bit.SeederApi.Services; + +namespace Bit.SeederApi.Execution; + +public class SceneExecutor( + ILogger logger, + IServiceProvider serviceProvider) : ISceneExecutor +{ + + public async Task ExecuteAsync(string templateName, JsonElement? arguments) + { + try + { + var scene = serviceProvider.GetKeyedService(templateName) + ?? throw new SceneNotFoundException(templateName); + + var requestType = scene.GetRequestType(); + var requestModel = DeserializeRequestModel(templateName, requestType, arguments); + var result = await scene.SeedAsync(requestModel); + + logger.LogInformation("Successfully executed scene: {TemplateName}", templateName); + return SceneResponseModel.FromSceneResult(result); + } + catch (Exception ex) when (ex is not SceneNotFoundException and not SceneExecutionException) + { + logger.LogError(ex, "Unexpected error executing scene: {TemplateName}", templateName); + throw new SceneExecutionException( + $"An unexpected error occurred while executing scene '{templateName}'", + ex.InnerException ?? ex); + } + } + + private object DeserializeRequestModel(string templateName, Type requestType, JsonElement? arguments) + { + if (arguments == null) + { + return CreateDefaultRequestModel(templateName, requestType); + } + + try + { + var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options); + if (requestModel == null) + { + throw new SceneExecutionException( + $"Failed to deserialize request model for scene '{templateName}'"); + } + return requestModel; + } + catch (JsonException ex) + { + throw new SceneExecutionException( + $"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex); + } + } + + private object CreateDefaultRequestModel(string templateName, Type requestType) + { + try + { + var requestModel = Activator.CreateInstance(requestType); + if (requestModel == null) + { + throw new SceneExecutionException( + $"Arguments are required for scene '{templateName}'"); + } + return requestModel; + } + catch + { + throw new SceneExecutionException( + $"Arguments are required for scene '{templateName}'"); + } + } +} diff --git a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..052da28dfc --- /dev/null +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using Bit.Seeder; +using Bit.SeederApi.Commands; +using Bit.SeederApi.Commands.Interfaces; +using Bit.SeederApi.Execution; +using Bit.SeederApi.Queries; +using Bit.SeederApi.Queries.Interfaces; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.SeederApi.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers SeederApi executors, commands, and queries. + /// + public static IServiceCollection AddSeederApiServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + return services; + } + + /// + /// Dynamically registers all scene types that implement IScene<TRequest> from the Seeder assembly. + /// Scenes are registered as keyed scoped services using their class name as the key. + /// + public static IServiceCollection AddScenes(this IServiceCollection services) + { + var iSceneType1 = typeof(IScene<>); + var iSceneType2 = typeof(IScene<,>); + var isIScene = (Type t) => t == iSceneType1 || t == iSceneType2; + + var seederAssembly = Assembly.Load("Seeder"); + var sceneTypes = seederAssembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.GetInterfaces().Any(i => i.IsGenericType && + isIScene(i.GetGenericTypeDefinition()))); + + foreach (var sceneType in sceneTypes) + { + services.TryAddScoped(sceneType); + services.TryAddKeyedScoped(typeof(IScene), sceneType.Name, (sp, _) => sp.GetRequiredService(sceneType)); + } + + return services; + } + + /// + /// Dynamically registers all query types that implement IQuery<TRequest> from the Seeder assembly. + /// Queries are registered as keyed scoped services using their class name as the key. + /// + public static IServiceCollection AddQueries(this IServiceCollection services) + { + var iQueryType = typeof(IQuery<,>); + var seederAssembly = Assembly.Load("Seeder"); + var queryTypes = seederAssembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.GetInterfaces().Any(i => i.IsGenericType && + i.GetGenericTypeDefinition() == iQueryType)); + + foreach (var queryType in queryTypes) + { + services.TryAddScoped(queryType); + services.TryAddKeyedScoped(typeof(IQuery), queryType.Name, (sp, _) => sp.GetRequiredService(queryType)); + } + + return services; + } +} diff --git a/util/SeederApi/Models/Request/QueryRequestModel.cs b/util/SeederApi/Models/Request/QueryRequestModel.cs new file mode 100644 index 0000000000..38751bc21b --- /dev/null +++ b/util/SeederApi/Models/Request/QueryRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Bit.SeederApi.Models.Request; + +public class QueryRequestModel +{ + [Required] + public required string Template { get; set; } + public JsonElement? Arguments { get; set; } +} diff --git a/util/SeederApi/Models/Request/SeedRequestModel.cs b/util/SeederApi/Models/Request/SeedRequestModel.cs new file mode 100644 index 0000000000..404af97ebe --- /dev/null +++ b/util/SeederApi/Models/Request/SeedRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Bit.SeederApi.Models.Request; + +public class SeedRequestModel +{ + [Required] + public required string Template { get; set; } + public JsonElement? Arguments { get; set; } +} diff --git a/util/SeederApi/Models/Response/SeedResponseModel.cs b/util/SeederApi/Models/Response/SeedResponseModel.cs new file mode 100644 index 0000000000..a8913c76c8 --- /dev/null +++ b/util/SeederApi/Models/Response/SeedResponseModel.cs @@ -0,0 +1,18 @@ +using Bit.Seeder; + +namespace Bit.SeederApi.Models.Response; + +public class SceneResponseModel +{ + public required Dictionary? MangleMap { get; init; } + public required object? Result { get; init; } + + public static SceneResponseModel FromSceneResult(SceneResult sceneResult) + { + return new SceneResponseModel + { + Result = sceneResult.Result, + MangleMap = sceneResult.MangleMap, + }; + } +} diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs new file mode 100644 index 0000000000..2067df307a --- /dev/null +++ b/util/SeederApi/Program.cs @@ -0,0 +1,20 @@ +using Bit.Core.Utilities; + +namespace Bit.SeederApi; + +public class Program +{ + public static void Main(string[] args) + { + Host + .CreateDefaultBuilder(args) + .ConfigureCustomAppConfiguration(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }) + .AddSerilogFileLogging() + .Build() + .Run(); + } +} diff --git a/util/SeederApi/Properties/launchSettings.json b/util/SeederApi/Properties/launchSettings.json new file mode 100644 index 0000000000..95cd77e255 --- /dev/null +++ b/util/SeederApi/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5047", + "sslPort": 0 + } + }, + "profiles": { + "SeederApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5047", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "SeederApi-SelfHost": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5048", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "developSelfHosted": "true" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/util/SeederApi/Queries/GetAllPlayIdsQuery.cs b/util/SeederApi/Queries/GetAllPlayIdsQuery.cs new file mode 100644 index 0000000000..7bc72e5b07 --- /dev/null +++ b/util/SeederApi/Queries/GetAllPlayIdsQuery.cs @@ -0,0 +1,15 @@ +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.SeederApi.Queries.Interfaces; + +namespace Bit.SeederApi.Queries; + +public class GetAllPlayIdsQuery(DatabaseContext databaseContext) : IGetAllPlayIdsQuery +{ + public List GetAllPlayIds() + { + return databaseContext.PlayItem + .Select(pd => pd.PlayId) + .Distinct() + .ToList(); + } +} diff --git a/util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs b/util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs new file mode 100644 index 0000000000..ea9c44991a --- /dev/null +++ b/util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs @@ -0,0 +1,13 @@ +namespace Bit.SeederApi.Queries.Interfaces; + +/// +/// Query for retrieving all play IDs for currently tracked seeded data. +/// +public interface IGetAllPlayIdsQuery +{ + /// + /// Retrieves all play IDs for currently tracked seeded data. + /// + /// A list of play IDs representing active seeded data that can be destroyed. + List GetAllPlayIds(); +} diff --git a/util/SeederApi/README.md b/util/SeederApi/README.md new file mode 100644 index 0000000000..a5a5d4ab9f --- /dev/null +++ b/util/SeederApi/README.md @@ -0,0 +1,185 @@ +# SeederApi + +A web API for dynamically seeding and querying test data in the Bitwarden database during development and testing. + +## Overview + +The SeederApi provides HTTP endpoints to execute [Seeder](../Seeder/README.md) scenes and queries, enabling automated test data +generation and retrieval through a RESTful interface. This is particularly useful for integration testing, local +development workflows, and automated test environments. + +## Architecture + +The SeederApi consists of three main components: + +1. **Controllers** - HTTP endpoints for seeding, querying, and managing test data +2. **Services** - Business logic for scene and query execution +3. **Models** - Request/response models for API communication + +### Key Components + +- **SeedController** (`/seed`) - Creates and destroys seeded test data +- **QueryController** (`/query`) - Executes read-only queries against existing data +- **InfoController** (`/alive`, `/version`) - Health check and version information +- **SceneService** - Manages scene execution and cleanup with play ID tracking +- **QueryService** - Executes read-only query operations + +## How To Use + +### Starting the API + +```bash +cd util/SeederApi +dotnet run +``` + +The API will start on the configured port (typically `http://localhost:5000`). + +### Seeding Data + +Send a POST request to `/seed` with a scene template name and optional arguments. Include the `X-Play-Id` header to +track the seeded data for later cleanup: + +```bash +curl -X POST http://localhost:5000/seed \ + -H "Content-Type: application/json" \ + -H "X-Play-Id: test-run-123" \ + -d '{ + "template": "SingleUserScene", + "arguments": { + "email": "test@example.com" + } + }' +``` + +**Response:** + +```json +{ + "mangleMap": { + "test@example.com": "1854b016+test@example.com", + "42bcf05d-7ad0-4e27-8b53-b3b700acc664": "42bcf05d-7ad0-4e27-8b53-b3b700acc664" + }, + "result": null +} +``` + +The `result` contains the data returned by the scene, and `mangleMap` contains ID mappings if ID mangling is enabled. +Use the `X-Play-Id` header value to later destroy the seeded data. + +### Querying Data + +Send a POST request to `/query` to execute read-only queries: + +```bash +curl -X POST http://localhost:5000/query \ + -H "Content-Type: application/json" \ + -d '{ + "template": "EmergencyAccessInviteQuery", + "arguments": { + "email": "test@example.com" + } + }' +``` + +**Response:** + +```json +["/accept-emergency?..."] +``` + +### Destroying Seeded Data + +#### Delete by Play ID + +Use the same play ID value you provided in the `X-Play-Id` header: + +```bash +curl -X DELETE http://localhost:5000/seed/test-run-123 +``` + +#### Delete Multiple by Play IDs + +```bash +curl -X DELETE http://localhost:5000/seed/batch \ + -H "Content-Type: application/json" \ + -d '["test-run-123", "test-run-456"]' +``` + +#### Delete All Seeded Data + +```bash +curl -X DELETE http://localhost:5000/seed +``` + +### Health Checks + +```bash +# Check if API is alive +curl http://localhost:5000/alive + +# Get API version +curl http://localhost:5000/version +``` + +## Creating Scenes and Queries + +Scenes and queries are defined in the [Seeder](../Seeder/README.md) project. The SeederApi automatically discovers and registers all +classes implementing the scene and query interfaces. + +## Configuration + +The SeederApi uses the standard Bitwarden configuration system: + +- `appsettings.json` - Base configuration +- `appsettings.Development.json` - Development overrides +- `dev/secrets.json` - Local secrets (database connection strings, etc.) +- User Secrets ID: `bitwarden-seeder-api` + +### Required Settings + +The SeederApi requires the following configuration: + +- **Database Connection** - Connection string to the Bitwarden database +- **Global Settings** - Standard Bitwarden `GlobalSettings` configuration + +## Play ID Tracking + +Certain entities such as Users and Organizations are tracked when created by a request including a PlayId. This enables +entities to be deleted after using the PlayId. + +### The X-Play-Id Header + +**Important:** All seed requests should include the `X-Play-Id` header: + +```bash +-H "X-Play-Id: your-unique-identifier" +``` + +The play ID can be any string that uniquely identifies your test run or session. Common patterns: + +### How Play ID Tracking Works + +When `TestPlayIdTrackingEnabled` is enabled in GlobalSettings, the `PlayIdMiddleware` +(see `src/SharedWeb/Utilities/PlayIdMiddleware.cs:7-23`) automatically: + +1. **Extracts** the `X-Play-Id` header from incoming requests +2. **Sets** the play ID in the `PlayIdService` for the request scope +3. **Tracks** all entities (users, organizations, etc.) created during the request +4. **Associates** them with the play ID in the `PlayItem` table +5. **Enables** complete cleanup via the delete endpoints + +This tracking works for **any API request** that includes the `X-Play-Id` header, not just SeederApi endpoints. This means +you can track entities created through: + +- **Scene executions** - Data seeded via `/seed` endpoint +- **Regular API operations** - Users signing up, creating organizations, inviting members, etc. +- **Integration tests** - Any HTTP requests to the Bitwarden API during test execution + +Without the `X-Play-Id` header, entities will not be tracked and cannot be cleaned up using the delete endpoints. + +## Security Considerations + +> [!WARNING] +> The SeederApi is intended for **development and testing environments only**. Never deploy this API to production +> environments. diff --git a/util/SeederApi/SeederApi.csproj b/util/SeederApi/SeederApi.csproj new file mode 100644 index 0000000000..53e9941c1c --- /dev/null +++ b/util/SeederApi/SeederApi.csproj @@ -0,0 +1,16 @@ + + + + bitwarden-seeder-api + net8.0 + enable + enable + false + + + + + + + + diff --git a/util/SeederApi/Services/QueryExceptions.cs b/util/SeederApi/Services/QueryExceptions.cs new file mode 100644 index 0000000000..beb0625cbb --- /dev/null +++ b/util/SeederApi/Services/QueryExceptions.cs @@ -0,0 +1,10 @@ +namespace Bit.SeederApi.Services; + +public class QueryNotFoundException(string query) : Exception($"Query '{query}' not found"); + +public class QueryExecutionException : Exception +{ + public QueryExecutionException(string message) : base(message) { } + public QueryExecutionException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/util/SeederApi/Services/SceneExceptions.cs b/util/SeederApi/Services/SceneExceptions.cs new file mode 100644 index 0000000000..2d8da19629 --- /dev/null +++ b/util/SeederApi/Services/SceneExceptions.cs @@ -0,0 +1,10 @@ +namespace Bit.SeederApi.Services; + +public class SceneNotFoundException(string scene) : Exception($"Scene '{scene}' not found"); + +public class SceneExecutionException : Exception +{ + public SceneExecutionException(string message) : base(message) { } + public SceneExecutionException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/util/SeederApi/Startup.cs b/util/SeederApi/Startup.cs new file mode 100644 index 0000000000..420078f509 --- /dev/null +++ b/util/SeederApi/Startup.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using Bit.Core.Settings; +using Bit.Seeder; +using Bit.Seeder.Factories; +using Bit.SeederApi.Extensions; +using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.SeederApi; + +public class Startup +{ + public Startup(IWebHostEnvironment env, IConfiguration configuration) + { + CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); + Configuration = configuration; + Environment = env; + } + + public IConfiguration Configuration { get; private set; } + public IWebHostEnvironment Environment { get; set; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddOptions(); + + var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); + + services.AddCustomDataProtectionServices(Environment, globalSettings); + + services.AddTokenizers(); + services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); + + services.TryAddSingleton(); + + services.AddScoped, PasswordHasher>(); + + services.AddSingleton(); + services.AddScoped(); + + services.AddSeederApiServices(); + + services.AddScoped(_ => new MangleId()); + services.AddScenes(); + services.AddQueries(); + + services.AddControllers(); + } + + public void Configure( + IApplicationBuilder app, + IWebHostEnvironment env, + IHostApplicationLifetime appLifetime, + GlobalSettings globalSettings) + { + if (env.IsProduction()) + { + throw new InvalidOperationException( + "SeederApi cannot be run in production environments. This service is intended for test data generation only."); + } + + if (globalSettings.TestPlayIdTrackingEnabled) + { + app.UseMiddleware(); + } + + if (!env.IsDevelopment()) + { + app.UseExceptionHandler("/Home/Error"); + } + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}"); + }); + } +} diff --git a/util/SeederApi/appsettings.Development.json b/util/SeederApi/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/util/SeederApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/util/SeederApi/appsettings.json b/util/SeederApi/appsettings.json new file mode 100644 index 0000000000..79388a1bb0 --- /dev/null +++ b/util/SeederApi/appsettings.json @@ -0,0 +1,11 @@ +{ + "globalSettings": { + "projectName": "SeederApi" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/util/Server/Server.csproj b/util/Server/Server.csproj index 5aaeee7f4a..c64019e154 100644 --- a/util/Server/Server.csproj +++ b/util/Server/Server.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/util/Setup/Setup.csproj b/util/Setup/Setup.csproj index 6366d46d3d..b4ab0bd806 100644 --- a/util/Setup/Setup.csproj +++ b/util/Setup/Setup.csproj @@ -3,6 +3,8 @@ Exe 1701;1702;1705;NU1701 + + $(WarningsNotAsErrors);CA1305 diff --git a/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj b/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj index 47001803ad..a2fb8173bf 100644 --- a/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj +++ b/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj @@ -1,4 +1,9 @@ + + + $(WarningsNotAsErrors);CA1305 + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs new file mode 100644 index 0000000000..eac0b557a4 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs @@ -0,0 +1,3491 @@ +// +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("20260108193841_CreatePlayItem")] + partial class CreatePlayItem + { + /// + 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("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseDisableSmAdsForUsers") + .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("UsePhishingBlocker") + .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", "UsersGetPremium" }); + + 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(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.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.Dirt.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.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + 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.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AuthType") + .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(4000) + .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("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .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.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (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.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + 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("Archives") + .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.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.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.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + 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.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + 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.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + 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("SecretVersions"); + + 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/20260108193841_CreatePlayItem.cs b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.cs new file mode 100644 index 0000000000..ae62e38f23 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class CreatePlayItem : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayItem", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PlayId = table.Column(type: "TEXT", maxLength: 256, nullable: false), + UserId = table.Column(type: "TEXT", nullable: true), + OrganizationId = table.Column(type: "TEXT", nullable: true), + CreationDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayItem", x => x.Id); + table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_PlayItem_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayItem_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_OrganizationId", + table: "PlayItem", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_PlayId", + table: "PlayItem", + column: "PlayId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_UserId", + table: "PlayItem", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayItem"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index a30b959ce9..4605ae8939 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -277,71 +277,6 @@ namespace Bit.SqliteMigrations.Migrations 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") @@ -621,7 +556,7 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Type") .HasColumnType("INTEGER"); - b.Property("WaitTimeDays") + b.Property("WaitTimeDays") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -1004,6 +939,71 @@ namespace Bit.SqliteMigrations.Migrations b.ToTable("OrganizationApplication", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.Dirt.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.Dirt.Models.OrganizationReport", b => { b.Property("Id") @@ -1616,6 +1616,42 @@ namespace Bit.SqliteMigrations.Migrations b.ToTable("OrganizationUser", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.Property("Id") @@ -2596,28 +2632,6 @@ namespace Bit.SqliteMigrations.Migrations 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") @@ -2796,6 +2810,28 @@ namespace Bit.SqliteMigrations.Migrations b.Navigation("Organization"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -2992,6 +3028,23 @@ namespace Bit.SqliteMigrations.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/SqliteMigrations/SqliteMigrations.csproj b/util/SqliteMigrations/SqliteMigrations.csproj index dce863036f..26def0dad2 100644 --- a/util/SqliteMigrations/SqliteMigrations.csproj +++ b/util/SqliteMigrations/SqliteMigrations.csproj @@ -3,6 +3,8 @@ enable enable + + $(WarningsNotAsErrors);CA1305