diff --git a/util/Seeder/IQuery.cs b/util/Seeder/IQuery.cs new file mode 100644 index 0000000000..706a1cc709 --- /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 index 26e32762de..e7ebb1efa0 100644 --- a/util/Seeder/IScene.cs +++ b/util/Seeder/IScene.cs @@ -3,13 +3,13 @@ namespace Bit.Seeder; public interface IScene { Type GetRequestType(); - RecipeResult Seed(object request); + SceneResult Seed(object request); } public interface IScene : IScene where TRequest : class { - RecipeResult Seed(TRequest request); + SceneResult Seed(TRequest request); Type IScene.GetRequestType() => typeof(TRequest); - RecipeResult IScene.Seed(object request) => Seed((TRequest)request); + SceneResult IScene.Seed(object request) => Seed((TRequest)request); } diff --git a/util/Seeder/Recipes/EmergencyAccessInviteRecipe.cs b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs similarity index 57% rename from util/Seeder/Recipes/EmergencyAccessInviteRecipe.cs rename to util/Seeder/Queries/EmergencyAccessInviteQuery.cs index c6d550e5bf..e4545ef9db 100644 --- a/util/Seeder/Recipes/EmergencyAccessInviteRecipe.cs +++ b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs @@ -1,18 +1,26 @@ -using Bit.Core.Auth.Enums; +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.Recipes; +namespace Bit.Seeder.Queries; -public class EmergencyAccessInviteRecipe( +public class EmergencyAccessInviteQuery( DatabaseContext db, IDataProtectorTokenFactory dataProtectorTokenizer) + : IQuery { - public RecipeResult Seed(string email) + public class Request + { + [Required] + public required string Email { get; set; } + } + + public object Execute(Request request) { var invites = db.EmergencyAccesses - .Where(ea => ea.Email == email).ToList().Select(ea => + .Where(ea => ea.Email == request.Email).ToList().Select(ea => { var token = dataProtectorTokenizer.Protect( new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1) @@ -20,9 +28,6 @@ public class EmergencyAccessInviteRecipe( return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}"; }); - return new RecipeResult - { - Result = invites, - }; + return invites; } } diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index 4512d32f7a..53daddd7bf 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -7,9 +7,8 @@ namespace Bit.Seeder.Recipes; public class OrganizationWithUsersRecipe(DatabaseContext db) { - public RecipeResult Seed(string name, int users, string domain) + public Guid Seed(string name, int users, string domain) { - var mangleId = Guid.NewGuid(); var organization = OrganizationSeeder.CreateEnterprise(name, domain, users); var user = UserSeeder.CreateUserNoMangle($"admin@{domain}"); var orgUser = organization.CreateOrganizationUser(user); @@ -33,14 +32,6 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) db.BulkCopy(additionalUsers); db.BulkCopy(additionalOrgUsers); - return new RecipeResult - { - Result = organization.Id, - TrackedEntities = new Dictionary> - { - ["Organization"] = [organization.Id], - ["User"] = [user.Id, .. additionalUsers.Select(u => u.Id)] - } - }; + return organization.Id; } } diff --git a/util/Seeder/RecipeResult.cs b/util/Seeder/SceneResult.cs similarity index 86% rename from util/Seeder/RecipeResult.cs rename to util/Seeder/SceneResult.cs index 8e3a5ba55a..4d33fb3486 100644 --- a/util/Seeder/RecipeResult.cs +++ b/util/Seeder/SceneResult.cs @@ -1,6 +1,6 @@ namespace Bit.Seeder; -public class RecipeResult +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 index b68cde964e..f12d0b5cc7 100644 --- a/util/Seeder/Scenes/SingleUserScene.cs +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -15,14 +15,14 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene public bool Premium { get; set; } = false; } - public RecipeResult Seed(Request request) + public SceneResult Seed(Request request) { var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium); db.Add(user); db.SaveChanges(); - return new RecipeResult + return new SceneResult { Result = userSeeder.GetMangleMap(user, new UserData { diff --git a/util/SeederApi/Controllers/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs index d71e13fc6f..cb816e3d8a 100644 --- a/util/SeederApi/Controllers/SeedController.cs +++ b/util/SeederApi/Controllers/SeedController.cs @@ -1,172 +1,165 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json; +using Bit.SeederApi.Models.Requests; +using Bit.SeederApi.Models.Response; using Bit.SeederApi.Services; using Microsoft.AspNetCore.Mvc; -namespace Bit.SeederApi.Controllers; - -public class SeedRequestModel +namespace Bit.SeederApi.Controllers { - [Required] - public required string Template { get; set; } - public JsonElement? Arguments { get; set; } -} - -[Route("")] -public class SeedController(ILogger logger, IRecipeService recipeService) - : Controller -{ - [HttpPost("/seed")] - public IActionResult Seed([FromBody] SeedRequestModel request) + [Route("")] + public class SeedController(ILogger logger, IRecipeService recipeService) + : Controller { - logger.LogInformation("Seeding with template: {Template}", request.Template); - - try + [HttpPost("/query")] + public IActionResult Query([FromBody] SeedRequestModel request) { - var (result, seedId) = recipeService.ExecuteRecipe(request.Template, request.Arguments); + 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 = "Seed completed successfully", - request.Template, - Result = result, - SeedId = seedId + Message = "Batch delete completed successfully" }); } - 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 - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error seeding with template: {Template}", request.Template); - return StatusCode(500, new - { - Error = "An unexpected error occurred while seeding", - Details = ex.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 () => + [HttpDelete("/seed/{seedId}")] + public async Task Delete([FromRoute] Guid seedId) { - foreach (var seedId in seedIds) + logger.LogInformation("Deleting seeded data with ID: {SeedId}", seedId); + + try { - try - { - await recipeService.DestroyRecipe(seedId); - } - catch (Exception ex) - { - aggregateException = new AggregateException(aggregateException, ex); - logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId); - } + var result = await recipeService.DestroyRecipe(seedId); + + return Json(result); } - }); - - if (aggregateException.InnerExceptions.Count > 0) - { - return BadRequest(new + catch (RecipeExecutionException ex) { - 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 Ok(new - { - Message = "Delete completed successfully", - Result = result - }); - } - catch (RecipeExecutionException ex) - { - logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId); - return BadRequest(new - { - Error = ex.Message, - Details = ex.InnerException?.Message - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error deleting seeded data: {SeedId}", seedId); - return StatusCode(500, new - { - Error = "An unexpected error occurred while deleting", - Details = ex.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 + logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId); + return BadRequest(new { - await recipeService.DestroyRecipe(sd.Id); - } - catch (Exception ex) - { - aggregateException = new AggregateException(aggregateException, ex); - logger.LogError(ex, "Error deleting seeded data: {SeedId}", sd.Id); - } + Error = ex.Message, + Details = ex.InnerException?.Message + }); } - }); - - 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 + + + [HttpDelete("/seed")] + public async Task DeleteAll() { - Message = "All seeded data deleted successfully" - }); + 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 index 46633cbeef..aaf2cef0b4 100644 --- a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -21,7 +21,28 @@ public static class ServiceCollectionExtensions foreach (var sceneType in sceneTypes) { services.TryAddScoped(sceneType); - services.TryAddKeyedScoped(typeof(IScene), sceneType.Name, (sp, key) => sp.GetRequiredService(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 0000000000..bbbf3de0be --- /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 0000000000..6dd47991ec --- /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 index ebfedbcc34..e92d2ea4da 100644 --- a/util/SeederApi/Program.cs +++ b/util/SeederApi/Program.cs @@ -22,6 +22,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(_ => new MangleId()); builder.Services.AddScenes(); +builder.Services.AddQueries(); var app = builder.Build(); diff --git a/util/SeederApi/Services/IRecipeService.cs b/util/SeederApi/Services/IRecipeService.cs index 2cc78b6367..54165286bb 100644 --- a/util/SeederApi/Services/IRecipeService.cs +++ b/util/SeederApi/Services/IRecipeService.cs @@ -23,4 +23,15 @@ public interface IRecipeService /// 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/RecipeService.cs b/util/SeederApi/Services/RecipeService.cs index 03db850830..d920af7923 100644 --- a/util/SeederApi/Services/RecipeService.cs +++ b/util/SeederApi/Services/RecipeService.cs @@ -29,21 +29,16 @@ public class RecipeService( { var result = ExecuteRecipeMethod(templateName, arguments, "Seed"); - if (result is not RecipeResult recipeResult) + if (result.TrackedEntities.Count == 0) { - return (Result: result, SeedId: null); - } - - if (recipeResult.TrackedEntities.Count == 0) - { - return (Result: recipeResult.Result, SeedId: null); + return (Result: result.Result, SeedId: null); } var seededData = new SeededData { Id = Guid.NewGuid(), RecipeName = templateName, - Data = JsonSerializer.Serialize(recipeResult.TrackedEntities), + Data = JsonSerializer.Serialize(result.TrackedEntities), CreationDate = DateTime.UtcNow }; @@ -53,7 +48,68 @@ public class RecipeService( logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}", seededData.Id, templateName); - return (Result: recipeResult.Result, SeedId: seededData.Id); + 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) @@ -110,7 +166,7 @@ public class RecipeService( return new { SeedId = seedId, RecipeName = seededData.RecipeName }; } - private RecipeResult? ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName) + private SceneResult ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName) { try {