From 878b78b51eda224963ee99382447bc4271b3618c Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 29 Oct 2025 12:27:15 -0700 Subject: [PATCH] Stricter scene and query types SeederAPI only serves Scenes, Recipes are inteded to be used locally only. --- .../SeedControllerTest.cs | 13 +- util/Seeder/IQuery.cs | 6 +- util/Seeder/IScene.cs | 21 +- .../Queries/EmergencyAccessInviteQuery.cs | 7 +- util/Seeder/RecipeResult.cs | 6 + util/Seeder/SceneResult.cs | 34 ++- util/Seeder/Scenes/SingleUserScene.cs | 33 ++- util/SeederApi/Controllers/QueryController.cs | 51 ++-- util/SeederApi/Controllers/SeedController.cs | 238 +++++++++--------- .../Extensions/ServiceCollectionExtensions.cs | 11 +- .../Models/Response/SeedResponseModel.cs | 21 +- util/SeederApi/Program.cs | 2 +- util/SeederApi/Services/IRecipeService.cs | 5 +- util/SeederApi/Services/RecipeService.cs | 17 +- 14 files changed, 261 insertions(+), 204 deletions(-) create mode 100644 util/Seeder/RecipeResult.cs diff --git a/test/SeederApi.IntegrationTest/SeedControllerTest.cs b/test/SeederApi.IntegrationTest/SeedControllerTest.cs index 547d0bf98c..f41c851dd7 100644 --- a/test/SeederApi.IntegrationTest/SeedControllerTest.cs +++ b/test/SeederApi.IntegrationTest/SeedControllerTest.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using Bit.SeederApi.Models.Requests; using Bit.SeederApi.Models.Response; using Xunit; @@ -40,11 +40,12 @@ public class SeedControllerTests : IClassFixture, I }); response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(); + var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.NotEqual(Guid.Empty, result.SeedId); - Assert.NotNull(result.Result); + Assert.NotNull(result.MangleMap); + Assert.Null(result.Result); } [Fact] @@ -83,7 +84,7 @@ public class SeedControllerTests : IClassFixture, I }); seedResponse.EnsureSuccessStatusCode(); - var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(seedResult); var deleteResponse = await _client.DeleteAsync($"/seed/{seedResult.SeedId}"); @@ -117,7 +118,7 @@ public class SeedControllerTests : IClassFixture, I }); seedResponse.EnsureSuccessStatusCode(); - var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(seedResult); Assert.NotNull(seedResult.SeedId); seedIds.Add(seedResult.SeedId.Value); @@ -149,7 +150,7 @@ public class SeedControllerTests : IClassFixture, I }); seedResponse.EnsureSuccessStatusCode(); - var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(seedResult); // Try to delete with mix of valid and invalid IDs diff --git a/util/Seeder/IQuery.cs b/util/Seeder/IQuery.cs index 706a1cc709..7405208a40 100644 --- a/util/Seeder/IQuery.cs +++ b/util/Seeder/IQuery.cs @@ -1,4 +1,4 @@ -namespace Bit.Seeder; +namespace Bit.Seeder; public interface IQuery { @@ -6,9 +6,9 @@ public interface IQuery object Execute(object request); } -public interface IQuery : IQuery where TRequest : class +public interface IQuery : IQuery where TRequest : class where TResult : class { - object Execute(TRequest request); + TResult 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 e7ebb1efa0..b791ee22d8 100644 --- a/util/Seeder/IScene.cs +++ b/util/Seeder/IScene.cs @@ -1,15 +1,30 @@ -namespace Bit.Seeder; +namespace Bit.Seeder; public interface IScene { Type GetRequestType(); - SceneResult Seed(object request); + SceneResult Seed(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. +/// +/// public interface IScene : IScene where TRequest : class { SceneResult Seed(TRequest request); + Type IScene.GetRequestType() => typeof(TRequest); + SceneResult IScene.Seed(object request) + { + var result = Seed((TRequest)request); + return new SceneResult(mangleMap: result.MangleMap, trackedEntities: result.TrackedEntities); + } +} + +public interface IScene : IScene where TRequest : class where TResult : class +{ + SceneResult Seed(TRequest request); Type IScene.GetRequestType() => typeof(TRequest); - SceneResult IScene.Seed(object request) => Seed((TRequest)request); + SceneResult IScene.Seed(object request) => (SceneResult)Seed((TRequest)request); } diff --git a/util/Seeder/Queries/EmergencyAccessInviteQuery.cs b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs index e4545ef9db..d96840b56e 100644 --- a/util/Seeder/Queries/EmergencyAccessInviteQuery.cs +++ b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Auth.Enums; +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Tokens; using Bit.Infrastructure.EntityFramework.Repositories; @@ -9,7 +8,7 @@ namespace Bit.Seeder.Queries; public class EmergencyAccessInviteQuery( DatabaseContext db, IDataProtectorTokenFactory dataProtectorTokenizer) - : IQuery + : IQuery> { public class Request { @@ -17,7 +16,7 @@ public class EmergencyAccessInviteQuery( public required string Email { get; set; } } - public object Execute(Request request) + public IEnumerable Execute(Request request) { var invites = db.EmergencyAccesses .Where(ea => ea.Email == request.Email).ToList().Select(ea => diff --git a/util/Seeder/RecipeResult.cs b/util/Seeder/RecipeResult.cs new file mode 100644 index 0000000000..43a52a3342 --- /dev/null +++ b/util/Seeder/RecipeResult.cs @@ -0,0 +1,6 @@ +namespace Bit.Seeder; + +public class RecipeResult +{ + public Dictionary> TrackedEntities { get; init; } = new(); +} diff --git a/util/Seeder/SceneResult.cs b/util/Seeder/SceneResult.cs index 4d33fb3486..b732cef4c4 100644 --- a/util/Seeder/SceneResult.cs +++ b/util/Seeder/SceneResult.cs @@ -1,7 +1,35 @@ namespace Bit.Seeder; -public class SceneResult +public class SceneResult : SceneResult { - public required object Result { get; init; } - public Dictionary> TrackedEntities { get; init; } = new(); + public SceneResult(Dictionary mangleMap, Dictionary> trackedEntities) + : base(result: null, mangleMap: mangleMap, trackedEntities: trackedEntities) { } +} + +public class SceneResult +{ + public TResult Result { get; init; } + public Dictionary MangleMap { get; init; } + public Dictionary> TrackedEntities { get; init; } + + public SceneResult(TResult result, Dictionary mangleMap, Dictionary> trackedEntities) + { + Result = result; + MangleMap = mangleMap; + TrackedEntities = trackedEntities; + } + + public static explicit operator SceneResult(SceneResult v) + { + var result = v.Result; + + if (result is null) + { + return new SceneResult(result: null, mangleMap: v.MangleMap, trackedEntities: v.TrackedEntities); + } + else + { + return new SceneResult(result: result, mangleMap: v.MangleMap, trackedEntities: v.TrackedEntities); + } + } } diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs index f12d0b5cc7..d0dfa30da4 100644 --- a/util/Seeder/Scenes/SingleUserScene.cs +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Enums; +using System.ComponentModel.DataAnnotations; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; @@ -22,23 +21,19 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene db.Add(user); db.SaveChanges(); - return new SceneResult + return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData { - 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] - } - }; + Email = request.Email, + Id = user.Id, + Key = user.Key, + PublicKey = user.PublicKey, + PrivateKey = user.PrivateKey, + ApiKey = user.ApiKey, + Kdf = user.Kdf, + KdfIterations = user.KdfIterations, + }), trackedEntities: new Dictionary> + { + ["User"] = [user.Id] + }); } } diff --git a/util/SeederApi/Controllers/QueryController.cs b/util/SeederApi/Controllers/QueryController.cs index 357f3a6304..592109d28d 100644 --- a/util/SeederApi/Controllers/QueryController.cs +++ b/util/SeederApi/Controllers/QueryController.cs @@ -1,37 +1,36 @@ -using Bit.SeederApi.Models.Requests; +using Bit.SeederApi.Models.Requests; using Bit.SeederApi.Services; using Microsoft.AspNetCore.Mvc; -namespace Bit.SeederApi.Controllers +namespace Bit.SeederApi.Controllers; + +[Route("query")] +public class QueryController(ILogger logger, ISeedService recipeService) + : Controller { - [Route("query")] - public class QueryController(ILogger logger, IRecipeService recipeService) - : Controller + [HttpPost] + public IActionResult Query([FromBody] QueryRequestModel request) { - [HttpPost] - public IActionResult Query([FromBody] QueryRequestModel request) + logger.LogInformation("Executing query: {Query}", request.Template); + + try { - logger.LogInformation("Executing query: {Query}", request.Template); + var result = recipeService.ExecuteQuery(request.Template, request.Arguments); - try + 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 { - 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 - }); - } + Error = ex.Message, + Details = ex.InnerException?.Message + }); } } } diff --git a/util/SeederApi/Controllers/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs index e794fa135d..db2c655b2c 100644 --- a/util/SeederApi/Controllers/SeedController.cs +++ b/util/SeederApi/Controllers/SeedController.cs @@ -1,139 +1,133 @@ using Bit.SeederApi.Models.Requests; -using Bit.SeederApi.Models.Response; using Bit.SeederApi.Services; using Microsoft.AspNetCore.Mvc; -namespace Bit.SeederApi.Controllers +namespace Bit.SeederApi.Controllers; + +[Route("seed")] +public class SeedController(ILogger logger, ISeedService recipeService) + : Controller { - [Route("seed")] - public class SeedController(ILogger logger, IRecipeService recipeService) - : Controller + [HttpPost] + public IActionResult Seed([FromBody] SeedRequestModel request) { - [HttpPost] - public IActionResult Seed([FromBody] SeedRequestModel request) + logger.LogInformation("Seeding with template: {Template}", request.Template); + + try { - logger.LogInformation("Seeding with template: {Template}", request.Template); + var response = recipeService.ExecuteScene(request.Template, request.Arguments); - 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 - }); - } + return Json(response); } - - [HttpDelete("batch")] - public async Task DeleteBatch([FromBody] List seedIds) + catch (RecipeNotFoundException ex) { - 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" - }); + return NotFound(new { Error = ex.Message }); } - - [HttpDelete("{seedId}")] - public async Task Delete([FromRoute] Guid seedId) + catch (RecipeExecutionException ex) { - logger.LogInformation("Deleting seeded data with ID: {SeedId}", seedId); - - try + logger.LogError(ex, "Error executing scene: {Template}", request.Template); + return BadRequest(new { - 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] - 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); - } - } + 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 NoContent(); } } + + [HttpDelete("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("{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] + 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 index aaf2cef0b4..2a22b199d0 100644 --- a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using Bit.Seeder; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -12,11 +12,15 @@ public static class ServiceCollectionExtensions /// 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 && - i.GetGenericTypeDefinition().Name == "IScene`1")); + isIScene(i.GetGenericTypeDefinition()))); foreach (var sceneType in sceneTypes) { @@ -33,11 +37,12 @@ public static class ServiceCollectionExtensions /// 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().Name == "IQuery`1")); + i.GetGenericTypeDefinition() == iQueryType)); foreach (var queryType in queryTypes) { diff --git a/util/SeederApi/Models/Response/SeedResponseModel.cs b/util/SeederApi/Models/Response/SeedResponseModel.cs index 6dd47991ec..9b9244eb78 100644 --- a/util/SeederApi/Models/Response/SeedResponseModel.cs +++ b/util/SeederApi/Models/Response/SeedResponseModel.cs @@ -1,7 +1,20 @@ -namespace Bit.SeederApi.Models.Response; +using Bit.Seeder; -public class SeedResponseModel +namespace Bit.SeederApi.Models.Response; + +public class SceneResponseModel { - public Guid? SeedId { get; set; } - public object? Result { get; set; } + public required Guid? SeedId { get; init; } + public required Dictionary? MangleMap { get; init; } + public required object? Result { get; init; } + + public static SceneResponseModel FromSceneResult(SceneResult sceneResult, Guid? seedId) + { + return new SceneResponseModel + { + Result = sceneResult.Result, + MangleMap = sceneResult.MangleMap, + SeedId = seedId + }; + } } diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs index 86ed2363ea..af0d901c8d 100644 --- a/util/SeederApi/Program.cs +++ b/util/SeederApi/Program.cs @@ -19,7 +19,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(_ => new MangleId()); builder.Services.AddScenes(); builder.Services.AddQueries(); diff --git a/util/SeederApi/Services/IRecipeService.cs b/util/SeederApi/Services/IRecipeService.cs index 54165286bb..6e4e101f35 100644 --- a/util/SeederApi/Services/IRecipeService.cs +++ b/util/SeederApi/Services/IRecipeService.cs @@ -1,9 +1,10 @@ using System.Text.Json; using Bit.Infrastructure.EntityFramework.Models; +using Bit.SeederApi.Models.Response; namespace Bit.SeederApi.Services; -public interface IRecipeService +public interface ISeedService { /// /// Executes a scene with the given template name and arguments. @@ -13,7 +14,7 @@ public interface IRecipeService /// 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); + SceneResponseModel ExecuteScene(string templateName, JsonElement? arguments); /// /// Destroys data created by a scene using the seeded data ID. diff --git a/util/SeederApi/Services/RecipeService.cs b/util/SeederApi/Services/RecipeService.cs index d920af7923..96c4f2a527 100644 --- a/util/SeederApi/Services/RecipeService.cs +++ b/util/SeederApi/Services/RecipeService.cs @@ -3,16 +3,17 @@ using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder; +using Bit.SeederApi.Models.Response; namespace Bit.SeederApi.Services; -public class RecipeService( +public class SeedService( DatabaseContext databaseContext, - ILogger logger, + ILogger logger, IServiceProvider serviceProvider, IUserRepository userRepository, IOrganizationRepository organizationRepository) - : IRecipeService + : ISeedService { private static readonly JsonSerializerOptions _jsonOptions = new() { @@ -25,13 +26,13 @@ public class RecipeService( return databaseContext.SeededData.ToList(); } - public (object? Result, Guid? SeedId) ExecuteRecipe(string templateName, JsonElement? arguments) + public SceneResponseModel ExecuteScene(string templateName, JsonElement? arguments) { - var result = ExecuteRecipeMethod(templateName, arguments, "Seed"); + var result = ExecuteSceneMethod(templateName, arguments, "Seed"); if (result.TrackedEntities.Count == 0) { - return (Result: result.Result, SeedId: null); + return SceneResponseModel.FromSceneResult(result, seedId: null); } var seededData = new SeededData @@ -48,7 +49,7 @@ public class RecipeService( logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}", seededData.Id, templateName); - return (Result: result.Result, SeedId: seededData.Id); + return SceneResponseModel.FromSceneResult(result, seededData.Id); } public object ExecuteQuery(string queryName, JsonElement? arguments) @@ -166,7 +167,7 @@ public class RecipeService( return new { SeedId = seedId, RecipeName = seededData.RecipeName }; } - private SceneResult ExecuteRecipeMethod(string templateName, JsonElement? arguments, string methodName) + private SceneResult ExecuteSceneMethod(string templateName, JsonElement? arguments, string methodName) { try {