diff --git a/.vscode/launch.json b/.vscode/launch.json index c407ba5604ca..ce9e0a20d33d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -204,6 +204,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API", + "configurations": [ + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "cloud", + }, + "preLaunchTask": "buildSeederAPI", + }, { "name": "Admin Self Host", "configurations": [ @@ -270,6 +281,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 +333,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 +529,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 567f9b6e58cf..64824ca22d6e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -51,7 +51,7 @@ "buildSso", "buildIcons", "buildBilling", - "buildNotifications", + "buildNotifications" ], }, { @@ -186,6 +186,23 @@ "isDefault": true } }, + { + "label": "buildSeederAPI", + "hide": true, + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/util/SeederApi/SeederApi.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, { "label": "buildNotifications", "hide": true, diff --git a/bitwarden-server.sln b/bitwarden-server.sln index d2fc61166ebb..d75460f2df2b 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -136,6 +136,8 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -348,6 +350,10 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -404,6 +410,7 @@ 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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 old mode 100644 new mode 100755 index 96dff0463211..5013ca8bac64 --- 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/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 33a7e5279125..75e96ebc66b0 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -36,7 +36,7 @@ public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess existingEmergencyAccess.KeyEncrypted = KeyEncrypted; } existingEmergencyAccess.Type = Type; - existingEmergencyAccess.WaitTimeDays = WaitTimeDays; + existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays; return existingEmergencyAccess; } } diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index d855126468d9..36aaf46a8c61 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/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs index 43311795549e..0072f85e61a6 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs @@ -79,7 +79,7 @@ public async Task InviteAsync(User grantorUser, string emergenc Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, Type = accessType, - WaitTimeDays = waitTime, + WaitTimeDays = (short)waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; diff --git a/src/Infrastructure.EntityFramework/Models/SeededData.cs b/src/Infrastructure.EntityFramework/Models/SeededData.cs new file mode 100644 index 000000000000..3a7805c39975 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Models/SeededData.cs @@ -0,0 +1,12 @@ +namespace Bit.Infrastructure.EntityFramework.Models; + +public class SeededData +{ + public Guid Id { get; set; } + public required string RecipeName { get; set; } + /// + /// JSON blob containing all + /// + public required string Data { get; set; } + public DateTime CreationDate { get; set; } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index 7446abdd97a0..ef238b5d2cf0 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -87,6 +87,7 @@ public DatabaseContext(DbContextOptions options) public DbSet OrganizationInstallations { get; set; } public DbSet OrganizationReports { get; set; } public DbSet OrganizationApplications { get; set; } + public DbSet SeededData { get; set; } protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bc8df87599ca..02f724dcc7bf 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -89,12 +89,12 @@ namespace Bit.SharedWeb.Utilities; public static class ServiceCollectionExtensions { - public static SupportedDatabaseProviders AddDatabaseRepositories(this IServiceCollection services, GlobalSettings globalSettings) + public static SupportedDatabaseProviders AddDatabaseRepositories(this IServiceCollection services, GlobalSettings globalSettings, bool forceEf = false) { var (provider, connectionString) = GetDatabaseProvider(globalSettings); services.SetupEntityFramework(connectionString, provider); - if (provider != SupportedDatabaseProviders.SqlServer) + if (provider != SupportedDatabaseProviders.SqlServer && !forceEf) { services.AddPasswordManagerEFRepositories(globalSettings.SelfHosted); } diff --git a/src/Sql/dbo/Tables/Device.sql b/src/Sql/dbo/Tables/Device.sql index 66328afe54fc..53762424e137 100644 --- a/src/Sql/dbo/Tables/Device.sql +++ b/src/Sql/dbo/Tables/Device.sql @@ -12,7 +12,7 @@ [EncryptedPrivateKey] VARCHAR (MAX) NULL, [Active] BIT NOT NULL CONSTRAINT [DF_Device_Active] DEFAULT (1), CONSTRAINT [PK_Device] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_Device_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) + CONSTRAINT [FK_Device_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE ); GO diff --git a/src/Sql/dbo/Tables/SeededData.sql b/src/Sql/dbo/Tables/SeededData.sql new file mode 100644 index 000000000000..b1e1d6582111 --- /dev/null +++ b/src/Sql/dbo/Tables/SeededData.sql @@ -0,0 +1,6 @@ +CREATE TABLE [dbo].[SeededData] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [RecipeName] NVARCHAR (MAX) NOT NULL, + [Data] NVARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, +); diff --git a/util/Migrator/DbScripts/2025-10-07_00_SeededData.sql b/util/Migrator/DbScripts/2025-10-07_00_SeededData.sql new file mode 100644 index 000000000000..c70091240edd --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-07_00_SeededData.sql @@ -0,0 +1,10 @@ +IF OBJECT_ID('dbo.SeededData') IS NULL +BEGIN + CREATE TABLE [dbo].[SeededData] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [RecipeName] NVARCHAR (MAX) NOT NULL, + [Data] NVARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + ); +END +GO diff --git a/util/Migrator/DbScripts/2025-10-09_00_Device_AddUserCascadeDelete.sql b/util/Migrator/DbScripts/2025-10-09_00_Device_AddUserCascadeDelete.sql new file mode 100644 index 000000000000..cbdb08ecbb07 --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-09_00_Device_AddUserCascadeDelete.sql @@ -0,0 +1,10 @@ +IF OBJECT_ID('[dbo].[FK_Device_User]', 'F') IS NOT NULL + BEGIN + ALTER TABLE [dbo].[Device] + DROP CONSTRAINT [FK_Device_User] + END +GO + +ALTER TABLE [dbo].[Device] + ADD CONSTRAINT [FK_Device_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE +GO diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index b24f8273b919..12ac824cfd18 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -1,13 +1,58 @@ using Bit.Core.Enums; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.Models; 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 +70,36 @@ public static User CreateUser(string email) }; } - 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 mangleMap = new Dictionary + { + { expectedUserData.Email, MangleEmail(expectedUserData.Email) }, + { expectedUserData.Id.ToString(), user.Id.ToString() }, + { expectedUserData.Kdf.ToString(), user.Kdf.ToString() }, + { expectedUserData.KdfIterations.ToString(), user.KdfIterations.ToString() } + }; + if (expectedUserData.Key != null) + { + mangleMap[expectedUserData.Key] = user.Key; + } - var user = new User + if (expectedUserData.PublicKey != null) { - Id = Guid.NewGuid(), - Email = email, - MasterPassword = null, - SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", - Key = keys.EncryptedUserKey, - PublicKey = keys.PublicKey, - PrivateKey = keys.PrivateKey, - ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", + mangleMap[expectedUserData.PublicKey] = user.PublicKey; + } - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 5_000, - }; + if (expectedUserData.PrivateKey != null) + { + mangleMap[expectedUserData.PrivateKey] = user.PrivateKey; + } - user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + if (expectedUserData.ApiKey != null) + { + mangleMap[expectedUserData.ApiKey] = user.ApiKey; + } - return (user, keys.Key); + return mangleMap; } + } diff --git a/util/Seeder/IQuery.cs b/util/Seeder/IQuery.cs new file mode 100644 index 000000000000..706a1cc70963 --- /dev/null +++ b/util/Seeder/IQuery.cs @@ -0,0 +1,15 @@ +namespace Bit.Seeder; + +public interface IQuery +{ + Type GetRequestType(); + object Execute(object request); +} + +public interface IQuery : IQuery where TRequest : class +{ + object Execute(TRequest request); + + Type IQuery.GetRequestType() => typeof(TRequest); + 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 000000000000..e7ebb1efa02d --- /dev/null +++ b/util/Seeder/IScene.cs @@ -0,0 +1,15 @@ +namespace Bit.Seeder; + +public interface IScene +{ + Type GetRequestType(); + SceneResult Seed(object request); +} + +public interface IScene : IScene where TRequest : class +{ + SceneResult Seed(TRequest request); + + Type IScene.GetRequestType() => typeof(TRequest); + SceneResult IScene.Seed(object request) => Seed((TRequest)request); +} diff --git a/util/Seeder/MangleId.cs b/util/Seeder/MangleId.cs new file mode 100644 index 000000000000..1ae8a93f4032 --- /dev/null +++ b/util/Seeder/MangleId.cs @@ -0,0 +1,14 @@ +namespace Bit.Seeder; + +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").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 000000000000..e4545ef9db55 --- /dev/null +++ b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Tokens; +using Bit.Infrastructure.EntityFramework.Repositories; + +namespace Bit.Seeder.Queries; + +public class EmergencyAccessInviteQuery( + DatabaseContext db, + IDataProtectorTokenFactory dataProtectorTokenizer) + : IQuery +{ + public class Request + { + [Required] + public required string Email { get; set; } + } + + public object 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 fb06c091ae86..53daddd7bf5b 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -10,14 +10,14 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) public Guid Seed(string name, int users, string domain) { var organization = OrganizationSeeder.CreateEnterprise(name, domain, users); - var user = UserSeeder.CreateUser($"admin@{domain}"); + var user = UserSeeder.CreateUserNoMangle($"admin@{domain}"); var orgUser = organization.CreateOrganizationUser(user); 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)); } diff --git a/util/Seeder/SceneResult.cs b/util/Seeder/SceneResult.cs new file mode 100644 index 000000000000..4d33fb3486a8 --- /dev/null +++ b/util/Seeder/SceneResult.cs @@ -0,0 +1,7 @@ +namespace Bit.Seeder; + +public class SceneResult +{ + public required object Result { get; init; } + public Dictionary> TrackedEntities { get; init; } = new(); +} diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs new file mode 100644 index 000000000000..f12d0b5cc716 --- /dev/null +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder.Factories; + +namespace Bit.Seeder.Scenes; + +public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene +{ + public class Request + { + [Required] + public required string Email { get; set; } + public bool EmailVerified { get; set; } = false; + public bool Premium { get; set; } = false; + } + + public SceneResult Seed(Request request) + { + var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium); + + db.Add(user); + db.SaveChanges(); + + return new SceneResult + { + Result = userSeeder.GetMangleMap(user, new UserData + { + Email = request.Email, + Id = Guid.Parse("00000000-0000-0000-0000-000000000001"), + Key = "seeded_key", + PublicKey = "seeded_public_key", + PrivateKey = "seeded_private_key", + ApiKey = "seeded_api_key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600_000, + }), + TrackedEntities = new Dictionary> + { + ["User"] = [user.Id] + } + }; + } +} diff --git a/util/SeederApi/Controllers/InfoController.cs b/util/SeederApi/Controllers/InfoController.cs new file mode 100644 index 000000000000..de4a264ddb1a --- /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/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs new file mode 100644 index 000000000000..cb816e3d8ab1 --- /dev/null +++ b/util/SeederApi/Controllers/SeedController.cs @@ -0,0 +1,165 @@ +using Bit.SeederApi.Models.Requests; +using Bit.SeederApi.Models.Response; +using Bit.SeederApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers +{ + [Route("")] + public class SeedController(ILogger logger, IRecipeService recipeService) + : Controller + { + [HttpPost("/query")] + public IActionResult Query([FromBody] SeedRequestModel request) + { + logger.LogInformation("Executing query: {Query}", request.Template); + + try + { + var result = recipeService.ExecuteQuery(request.Template, request.Arguments); + + return Json(new { Result = result }); + } + catch (RecipeNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (RecipeExecutionException ex) + { + logger.LogError(ex, "Error executing query: {Query}", request.Template); + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerException?.Message + }); + } + } + + [HttpPost("/seed")] + public IActionResult Seed([FromBody] SeedRequestModel request) + { + logger.LogInformation("Seeding with template: {Template}", request.Template); + + try + { + var (result, seedId) = recipeService.ExecuteRecipe(request.Template, request.Arguments); + + return Json(new SeedResponseModel + { + SeedId = seedId, + Result = result, + }); + } + catch (RecipeNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (RecipeExecutionException ex) + { + logger.LogError(ex, "Error executing scene: {Template}", request.Template); + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerException?.Message + }); + } + } + + [HttpDelete("/seed/batch")] + public async Task DeleteBatch([FromBody] List seedIds) + { + logger.LogInformation("Deleting batch of seeded data with IDs: {SeedIds}", string.Join(", ", seedIds)); + + var aggregateException = new AggregateException(); + + await Task.Run(async () => + { + foreach (var seedId in seedIds) + { + try + { + await recipeService.DestroyRecipe(seedId); + } + catch (Exception ex) + { + aggregateException = new AggregateException(aggregateException, ex); + logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId); + } + } + }); + + if (aggregateException.InnerExceptions.Count > 0) + { + return BadRequest(new + { + Error = "One or more errors occurred while deleting seeded data", + Details = aggregateException.InnerExceptions.Select(e => e.Message).ToList() + }); + } + return Ok(new + { + Message = "Batch delete completed successfully" + }); + } + + [HttpDelete("/seed/{seedId}")] + public async Task Delete([FromRoute] Guid seedId) + { + logger.LogInformation("Deleting seeded data with ID: {SeedId}", seedId); + + try + { + var result = await recipeService.DestroyRecipe(seedId); + + return Json(result); + } + catch (RecipeExecutionException ex) + { + logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId); + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerException?.Message + }); + } + } + + + [HttpDelete("/seed")] + public async Task DeleteAll() + { + logger.LogInformation("Deleting all seeded data"); + + // Pull all Seeded Data ids + var seededData = recipeService.GetAllSeededData(); + + var aggregateException = new AggregateException(); + + await Task.Run(async () => + { + foreach (var sd in seededData) + { + try + { + await recipeService.DestroyRecipe(sd.Id); + } + catch (Exception ex) + { + aggregateException = new AggregateException(aggregateException, ex); + logger.LogError(ex, "Error deleting seeded data: {SeedId}", sd.Id); + } + } + }); + + if (aggregateException.InnerExceptions.Count > 0) + { + return BadRequest(new + { + Error = "One or more errors occurred while deleting seeded data", + Details = aggregateException.InnerExceptions.Select(e => e.Message).ToList() + }); + } + return NoContent(); + } + } +} diff --git a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000000..aaf2cef0b40c --- /dev/null +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using Bit.Seeder; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.SeederApi.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Dynamically registers all scene types that implement IScene 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 seederAssembly = Assembly.Load("Seeder"); + var sceneTypes = seederAssembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.GetInterfaces().Any(i => i.IsGenericType && + i.GetGenericTypeDefinition().Name == "IScene`1")); + + 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 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 seederAssembly = Assembly.Load("Seeder"); + var queryTypes = seederAssembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.GetInterfaces().Any(i => i.IsGenericType && + i.GetGenericTypeDefinition().Name == "IQuery`1")); + + 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/SeedRequestModel.cs b/util/SeederApi/Models/Request/SeedRequestModel.cs new file mode 100644 index 000000000000..bbbf3de0be59 --- /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.Requests; + +public class SeedRequestModel +{ + [Required] + public required string Template { get; set; } + public JsonElement? Arguments { get; set; } +} \ No newline at end of file diff --git a/util/SeederApi/Models/Response/SeedResponseModel.cs b/util/SeederApi/Models/Response/SeedResponseModel.cs new file mode 100644 index 000000000000..6dd47991ec4c --- /dev/null +++ b/util/SeederApi/Models/Response/SeedResponseModel.cs @@ -0,0 +1,7 @@ +namespace Bit.SeederApi.Models.Response; + +public class SeedResponseModel +{ + public Guid? SeedId { get; set; } + public object? Result { get; set; } +} diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs new file mode 100644 index 000000000000..e92d2ea4da97 --- /dev/null +++ b/util/SeederApi/Program.cs @@ -0,0 +1,39 @@ +using Bit.Seeder; +using Bit.SeederApi.Extensions; +using Bit.SeederApi.Services; +using Bit.SharedWeb.Utilities; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); + +var globalSettings = builder.Services.AddGlobalSettingsServices(builder.Configuration, builder.Environment); + +// Common services +builder.Services.AddCustomDataProtectionServices(builder.Environment, globalSettings); +builder.Services.AddTokenizers(); +builder.Services.AddDatabaseRepositories(globalSettings, forceEf: true); + +builder.Services.AddScoped, Microsoft.AspNetCore.Identity.PasswordHasher>(); + +// Seeder services +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(_ => new MangleId()); +builder.Services.AddScenes(); +builder.Services.AddQueries(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); +} + +app.UseRouting(); + +app.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}"); + +app.Run(); diff --git a/util/SeederApi/Properties/launchSettings.json b/util/SeederApi/Properties/launchSettings.json new file mode 100644 index 000000000000..95cd77e255b3 --- /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/SeederApi.csproj b/util/SeederApi/SeederApi.csproj new file mode 100644 index 000000000000..53e9941c1cb4 --- /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/IRecipeService.cs b/util/SeederApi/Services/IRecipeService.cs new file mode 100644 index 000000000000..54165286bb9f --- /dev/null +++ b/util/SeederApi/Services/IRecipeService.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.SeederApi.Services; + +public interface IRecipeService +{ + /// + /// 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 tuple containing the result and optional seed ID for tracked entities + /// Thrown when the scene template is not found + /// Thrown when there's an error executing the scene + (object? Result, Guid? SeedId) ExecuteRecipe(string templateName, JsonElement? arguments); + + /// + /// 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 DestroyRecipe(Guid seedId); + List GetAllSeededData(); + + /// + /// 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 ExecuteQuery(string queryName, JsonElement? arguments); +} diff --git a/util/SeederApi/Services/RecipeExceptions.cs b/util/SeederApi/Services/RecipeExceptions.cs new file mode 100644 index 000000000000..21c95589d36c --- /dev/null +++ b/util/SeederApi/Services/RecipeExceptions.cs @@ -0,0 +1,10 @@ +namespace Bit.SeederApi.Services; + +public class RecipeNotFoundException(string recipe) : Exception($"Recipe '{recipe}' not found"); + +public class RecipeExecutionException : Exception +{ + public RecipeExecutionException(string message) : base(message) { } + public RecipeExecutionException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/util/SeederApi/Services/RecipeService.cs b/util/SeederApi/Services/RecipeService.cs new file mode 100644 index 000000000000..d920af79239b --- /dev/null +++ b/util/SeederApi/Services/RecipeService.cs @@ -0,0 +1,229 @@ +using System.Text.Json; +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder; + +namespace Bit.SeederApi.Services; + +public class RecipeService( + DatabaseContext databaseContext, + ILogger logger, + IServiceProvider serviceProvider, + IUserRepository userRepository, + IOrganizationRepository organizationRepository) + : IRecipeService +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public List GetAllSeededData() + { + return databaseContext.SeededData.ToList(); + } + + public (object? Result, Guid? SeedId) ExecuteRecipe(string templateName, JsonElement? arguments) + { + var result = ExecuteRecipeMethod(templateName, arguments, "Seed"); + + if (result.TrackedEntities.Count == 0) + { + return (Result: result.Result, SeedId: null); + } + + var seededData = new SeededData + { + Id = Guid.NewGuid(), + RecipeName = templateName, + Data = JsonSerializer.Serialize(result.TrackedEntities), + CreationDate = DateTime.UtcNow + }; + + databaseContext.Add(seededData); + databaseContext.SaveChanges(); + + logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}", + seededData.Id, templateName); + + return (Result: result.Result, SeedId: seededData.Id); + } + + public object ExecuteQuery(string queryName, JsonElement? arguments) + { + try + { + var query = serviceProvider.GetKeyedService(queryName) + ?? throw new RecipeNotFoundException(queryName); + + var requestType = query.GetRequestType(); + + // Deserialize the arguments into the request model + object? requestModel; + if (arguments == null) + { + // Try to create an instance with default values + try + { + requestModel = Activator.CreateInstance(requestType); + if (requestModel == null) + { + throw new RecipeExecutionException( + $"Arguments are required for query '{queryName}'"); + } + } + catch + { + throw new RecipeExecutionException( + $"Arguments are required for query '{queryName}'"); + } + } + else + { + try + { + requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, _jsonOptions); + if (requestModel == null) + { + throw new RecipeExecutionException( + $"Failed to deserialize request model for query '{queryName}'"); + } + } + catch (JsonException ex) + { + throw new RecipeExecutionException( + $"Failed to deserialize request model for query '{queryName}': {ex.Message}", ex); + } + } + + var result = query.Execute(requestModel); + + logger.LogInformation("Successfully executed query: {QueryName}", queryName); + return result; + } + catch (Exception ex) when (ex is not RecipeNotFoundException and not RecipeExecutionException) + { + logger.LogError(ex, "Unexpected error executing query: {QueryName}", queryName); + throw new RecipeExecutionException( + $"An unexpected error occurred while executing query '{queryName}'", + ex.InnerException ?? ex); + } + } + + public async Task DestroyRecipe(Guid seedId) + { + var seededData = databaseContext.SeededData.FirstOrDefault(s => s.Id == seedId); + if (seededData == null) + { + logger.LogInformation("No seeded data found with ID {SeedId}, skipping", seedId); + return null; + } + + var trackedEntities = JsonSerializer.Deserialize>>(seededData.Data); + if (trackedEntities == null) + { + throw new RecipeExecutionException($"Failed to deserialize tracked entities for seed ID {seedId}"); + } + + // Delete in reverse order to respect foreign key constraints + if (trackedEntities.TryGetValue("User", out var userIds)) + { + var users = databaseContext.Users.Where(u => userIds.Contains(u.Id)); + await userRepository.DeleteManyAsync(users); + } + + if (trackedEntities.TryGetValue("Organization", out var orgIds)) + { + var organizations = databaseContext.Organizations.Where(o => orgIds.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 RecipeExecutionException( + $"One or more errors occurred while deleting organizations for seed ID {seedId}", + aggregateException); + } + } + + databaseContext.Remove(seededData); + databaseContext.SaveChanges(); + + logger.LogInformation("Successfully destroyed seeded data with ID {SeedId} for scene {RecipeName}", + seedId, seededData.RecipeName); + + return new { SeedId = seedId, RecipeName = seededData.RecipeName }; + } + + private SceneResult ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName) + { + try + { + var scene = serviceProvider.GetKeyedService(templateName) + ?? throw new RecipeNotFoundException(templateName); + + var requestType = scene.GetRequestType(); + + // Deserialize the arguments into the request model + object? requestModel; + if (arguments == null) + { + // Try to create an instance with default values + try + { + requestModel = Activator.CreateInstance(requestType); + if (requestModel == null) + { + throw new RecipeExecutionException( + $"Arguments are required for scene '{templateName}'"); + } + } + catch + { + throw new RecipeExecutionException( + $"Arguments are required for scene '{templateName}'"); + } + } + else + { + try + { + requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, _jsonOptions); + if (requestModel == null) + { + throw new RecipeExecutionException( + $"Failed to deserialize request model for scene '{templateName}'"); + } + } + catch (JsonException ex) + { + throw new RecipeExecutionException( + $"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex); + } + } + + var result = scene.Seed(requestModel); + + logger.LogInformation("Successfully executed {MethodName} on scene: {TemplateName}", methodName, templateName); + return result; + } + catch (Exception ex) when (ex is not RecipeNotFoundException and not RecipeExecutionException) + { + logger.LogError(ex, "Unexpected error executing {MethodName} on scene: {TemplateName}", methodName, templateName); + throw new RecipeExecutionException( + $"An unexpected error occurred while executing {methodName} on scene '{templateName}'", + ex.InnerException ?? ex); + } + } +} diff --git a/util/SeederApi/appsettings.Development.json b/util/SeederApi/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /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 000000000000..79388a1bb0b5 --- /dev/null +++ b/util/SeederApi/appsettings.json @@ -0,0 +1,11 @@ +{ + "globalSettings": { + "projectName": "SeederApi" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +}