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/util/Seeder/IScene.cs b/util/Seeder/IScene.cs new file mode 100644 index 0000000000..26e32762de --- /dev/null +++ b/util/Seeder/IScene.cs @@ -0,0 +1,15 @@ +namespace Bit.Seeder; + +public interface IScene +{ + Type GetRequestType(); + RecipeResult Seed(object request); +} + +public interface IScene : IScene where TRequest : class +{ + RecipeResult Seed(TRequest request); + + Type IScene.GetRequestType() => typeof(TRequest); + RecipeResult IScene.Seed(object request) => Seed((TRequest)request); +} diff --git a/util/Seeder/Recipes/SingleUserRecipe.cs b/util/Seeder/Scenes/SingleUserScene.cs similarity index 56% rename from util/Seeder/Recipes/SingleUserRecipe.cs rename to util/Seeder/Scenes/SingleUserScene.cs index 5c80716dc1..b68cde964e 100644 --- a/util/Seeder/Recipes/SingleUserRecipe.cs +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -1,14 +1,23 @@ -using Bit.Core.Enums; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; -namespace Bit.Seeder.Recipes; +namespace Bit.Seeder.Scenes; -public class SingleUserRecipe(DatabaseContext db, UserSeeder userSeeder) +public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene { - public RecipeResult Seed(string email, bool emailVerified = false, bool premium = false) + public class Request { - var user = userSeeder.CreateUser(email, emailVerified, premium); + [Required] + public required string Email { get; set; } + public bool EmailVerified { get; set; } = false; + public bool Premium { get; set; } = false; + } + + public RecipeResult Seed(Request request) + { + var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium); db.Add(user); db.SaveChanges(); @@ -17,7 +26,7 @@ public class SingleUserRecipe(DatabaseContext db, UserSeeder userSeeder) { Result = userSeeder.GetMangleMap(user, new UserData { - Email = email, + Email = request.Email, Id = Guid.Parse("00000000-0000-0000-0000-000000000001"), Key = "seeded_key", PublicKey = "seeded_public_key", diff --git a/util/SeederApi/Controllers/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs index 683dc70fdd..d71e13fc6f 100644 --- a/util/SeederApi/Controllers/SeedController.cs +++ b/util/SeederApi/Controllers/SeedController.cs @@ -39,7 +39,7 @@ public class SeedController(ILogger logger, IRecipeService recip } catch (RecipeExecutionException ex) { - logger.LogError(ex, "Error executing recipe: {Template}", request.Template); + logger.LogError(ex, "Error executing scene: {Template}", request.Template); return BadRequest(new { Error = ex.Message, diff --git a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..46633cbeef --- /dev/null +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +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, key) => sp.GetRequiredService(sceneType)); + } + + return services; + } +} diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs index 2e64d09bf6..ebfedbcc34 100644 --- a/util/SeederApi/Program.cs +++ b/util/SeederApi/Program.cs @@ -1,4 +1,5 @@ using Bit.Seeder; +using Bit.SeederApi.Extensions; using Bit.SeederApi.Services; using Bit.SharedWeb.Utilities; @@ -20,6 +21,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(_ => new MangleId()); +builder.Services.AddScenes(); var app = builder.Build(); diff --git a/util/SeederApi/Services/IRecipeService.cs b/util/SeederApi/Services/IRecipeService.cs index 84bfe3ad9a..2cc78b6367 100644 --- a/util/SeederApi/Services/IRecipeService.cs +++ b/util/SeederApi/Services/IRecipeService.cs @@ -6,17 +6,17 @@ namespace Bit.SeederApi.Services; public interface IRecipeService { /// - /// Executes a recipe with the given template name and arguments. + /// Executes a scene with the given template name and arguments. /// - /// The name of the recipe template (e.g., "OrganizationWithUsersRecipe") - /// Optional JSON arguments to pass to the recipe's Seed method + /// 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 recipe template is not found - /// Thrown when there's an error executing the recipe + /// 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 recipe using the seeded data ID. + /// 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 diff --git a/util/SeederApi/Services/RecipeService.cs b/util/SeederApi/Services/RecipeService.cs index 431db5c9af..03db850830 100644 --- a/util/SeederApi/Services/RecipeService.cs +++ b/util/SeederApi/Services/RecipeService.cs @@ -1,5 +1,4 @@ -using System.Reflection; -using System.Text.Json; +using System.Text.Json; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories; @@ -15,6 +14,12 @@ public class RecipeService( IOrganizationRepository organizationRepository) : IRecipeService { + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + public List GetAllSeededData() { return databaseContext.SeededData.ToList(); @@ -45,7 +50,7 @@ public class RecipeService( databaseContext.Add(seededData); databaseContext.SaveChanges(); - logger.LogInformation("Saved seeded data with ID {SeedId} for recipe {RecipeName}", + logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}", seededData.Id, templateName); return (Result: recipeResult.Result, SeedId: seededData.Id); @@ -99,118 +104,70 @@ public class RecipeService( databaseContext.Remove(seededData); databaseContext.SaveChanges(); - logger.LogInformation("Successfully destroyed seeded data with ID {SeedId} for recipe {RecipeName}", + logger.LogInformation("Successfully destroyed seeded data with ID {SeedId} for scene {RecipeName}", seedId, seededData.RecipeName); return new { SeedId = seedId, RecipeName = seededData.RecipeName }; } - private object? ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName) + private RecipeResult? ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName) { try { - var recipeType = LoadRecipeType(templateName); - var method = GetRecipeMethod(recipeType, templateName, methodName); - var recipeInstance = CreateRecipeInstance(recipeType); + var scene = serviceProvider.GetKeyedService(templateName) + ?? throw new RecipeNotFoundException(templateName); - var methodArguments = ParseMethodArguments(method, arguments); - var result = method.Invoke(recipeInstance, methodArguments); + var requestType = scene.GetRequestType(); - logger.LogInformation("Successfully executed {MethodName} on recipe: {TemplateName}", methodName, templateName); - return result; - } - catch (Exception ex) when (ex is not RecipeNotFoundException and not RecipeExecutionException) - { - logger.LogError(ex, "Unexpected error executing {MethodName} on recipe: {TemplateName}", methodName, templateName); - throw new RecipeExecutionException( - $"An unexpected error occurred while executing {methodName} on recipe '{templateName}'", - ex.InnerException ?? ex); - } - } - - private static Type LoadRecipeType(string templateName) - { - var recipeTypeName = $"Bit.Seeder.Recipes.{templateName}"; - var recipeType = Assembly.Load("Seeder") - .GetTypes() - .FirstOrDefault(t => t.FullName == recipeTypeName); - - return recipeType ?? throw new RecipeNotFoundException(templateName); - } - - private static MethodInfo GetRecipeMethod(Type recipeType, string templateName, string methodName) - { - var method = recipeType.GetMethod(methodName); - return method ?? throw new RecipeExecutionException($"{methodName} method not found in recipe '{templateName}'"); - } - - private object CreateRecipeInstance(Type recipeType) - { - var constructors = recipeType.GetConstructors(); - if (constructors.Length == 0) - { - throw new RecipeExecutionException($"No public constructors found for recipe type '{recipeType.Name}'"); - } - - var constructor = constructors[0]; - var parameters = constructor.GetParameters(); - var constructorArgs = new object[parameters.Length]; - - for (var i = 0; i < parameters.Length; i++) - { - var parameter = parameters[i]; - var service = serviceProvider.GetService(parameter.ParameterType); - - if (service == null) + // Deserialize the arguments into the request model + object? requestModel; + if (arguments == null) { - throw new RecipeExecutionException( - $"Unable to resolve service of type '{parameter.ParameterType.Name}' for recipe constructor"); + // 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}'"); + } } - - constructorArgs[i] = service; - } - - return Activator.CreateInstance(recipeType, constructorArgs)!; - } - - private static object?[] ParseMethodArguments(MethodInfo seedMethod, JsonElement? arguments) - { - var parameters = seedMethod.GetParameters(); - - if (arguments == null && parameters.Length > 0) - { - throw new RecipeExecutionException("Arguments are required for this recipe"); - } - - var methodArguments = new object?[parameters.Length]; - - for (var i = 0; i < parameters.Length; i++) - { - var parameter = parameters[i]; - var parameterName = parameter.Name!; - - if (arguments?.TryGetProperty(parameterName, out var value) == true) + else { try { - methodArguments[i] = JsonSerializer.Deserialize(value.GetRawText(), parameter.ParameterType); + 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 parameter '{parameterName}': {ex.Message}", ex); + $"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex); } } - else if (!parameter.HasDefaultValue) - { - throw new RecipeExecutionException($"Missing required parameter: {parameterName}"); - } - else - { - methodArguments[i] = parameter.DefaultValue; - } - } - return methodArguments; + 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); + } } }