1
0
mirror of https://github.com/bitwarden/server synced 2025-12-11 13:53:40 +00:00

Stricter scene and query types

SeederAPI only serves Scenes, Recipes are inteded to be used locally only.
This commit is contained in:
Matt Gibson
2025-10-29 12:27:15 -07:00
parent 16ee5cfaad
commit 878b78b51e
14 changed files with 261 additions and 204 deletions

View File

@@ -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<SeederApiApplicationFactory>, I
});
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SeedResponseModel>();
var result = await response.Content.ReadFromJsonAsync<SceneResponseModel>();
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<SeederApiApplicationFactory>, I
});
seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SeedResponseModel>();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult);
var deleteResponse = await _client.DeleteAsync($"/seed/{seedResult.SeedId}");
@@ -117,7 +118,7 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
});
seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SeedResponseModel>();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult);
Assert.NotNull(seedResult.SeedId);
seedIds.Add(seedResult.SeedId.Value);
@@ -149,7 +150,7 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
});
seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SeedResponseModel>();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult);
// Try to delete with mix of valid and invalid IDs

View File

@@ -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<TRequest> : IQuery where TRequest : class
public interface IQuery<TRequest, TResult> : 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);

View File

@@ -1,15 +1,30 @@
namespace Bit.Seeder;
namespace Bit.Seeder;
public interface IScene
{
Type GetRequestType();
SceneResult Seed(object request);
SceneResult<object?> Seed(object request);
}
/// <summary>
/// Generic scene interface for seeding operations with a specific request type. Does not return a value beyond tracking entities and a mangle map.
/// </summary>
/// <typeparam name="TRequest"></typeparam>
public interface IScene<TRequest> : IScene where TRequest : class
{
SceneResult Seed(TRequest request);
Type IScene.GetRequestType() => typeof(TRequest);
SceneResult<object?> IScene.Seed(object request)
{
var result = Seed((TRequest)request);
return new SceneResult(mangleMap: result.MangleMap, trackedEntities: result.TrackedEntities);
}
}
public interface IScene<TRequest, TResult> : IScene where TRequest : class where TResult : class
{
SceneResult<TResult> Seed(TRequest request);
Type IScene.GetRequestType() => typeof(TRequest);
SceneResult IScene.Seed(object request) => Seed((TRequest)request);
SceneResult<object?> IScene.Seed(object request) => (SceneResult<object?>)Seed((TRequest)request);
}

View File

@@ -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<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
: IQuery<EmergencyAccessInviteQuery.Request>
: IQuery<EmergencyAccessInviteQuery.Request, IEnumerable<string>>
{
public class Request
{
@@ -17,7 +16,7 @@ public class EmergencyAccessInviteQuery(
public required string Email { get; set; }
}
public object Execute(Request request)
public IEnumerable<string> Execute(Request request)
{
var invites = db.EmergencyAccesses
.Where(ea => ea.Email == request.Email).ToList().Select(ea =>

View File

@@ -0,0 +1,6 @@
namespace Bit.Seeder;
public class RecipeResult
{
public Dictionary<string, List<Guid>> TrackedEntities { get; init; } = new();
}

View File

@@ -1,7 +1,35 @@
namespace Bit.Seeder;
public class SceneResult
public class SceneResult : SceneResult<object?>
{
public required object Result { get; init; }
public Dictionary<string, List<Guid>> TrackedEntities { get; init; } = new();
public SceneResult(Dictionary<string, string?> mangleMap, Dictionary<string, List<Guid>> trackedEntities)
: base(result: null, mangleMap: mangleMap, trackedEntities: trackedEntities) { }
}
public class SceneResult<TResult>
{
public TResult Result { get; init; }
public Dictionary<string, string?> MangleMap { get; init; }
public Dictionary<string, List<Guid>> TrackedEntities { get; init; }
public SceneResult(TResult result, Dictionary<string, string?> mangleMap, Dictionary<string, List<Guid>> trackedEntities)
{
Result = result;
MangleMap = mangleMap;
TrackedEntities = trackedEntities;
}
public static explicit operator SceneResult<object?>(SceneResult<TResult> v)
{
var result = v.Result;
if (result is null)
{
return new SceneResult<object?>(result: null, mangleMap: v.MangleMap, trackedEntities: v.TrackedEntities);
}
else
{
return new SceneResult<object?>(result: result, mangleMap: v.MangleMap, trackedEntities: v.TrackedEntities);
}
}
}

View File

@@ -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<string, List<Guid>>
{
["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<string, List<Guid>>
{
["User"] = [user.Id]
});
}
}

View File

@@ -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<QueryController> logger, ISeedService recipeService)
: Controller
{
[Route("query")]
public class QueryController(ILogger<QueryController> 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
});
}
}
}

View File

@@ -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<SeedController> logger, ISeedService recipeService)
: Controller
{
[Route("seed")]
public class SeedController(ILogger<SeedController> 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<IActionResult> DeleteBatch([FromBody] List<Guid> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteBatch([FromBody] List<Guid> 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<IActionResult> 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<IActionResult> 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();
}
}

View File

@@ -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
/// </summary>
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
/// </summary>
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)
{

View File

@@ -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<string, string?>? MangleMap { get; init; }
public required object? Result { get; init; }
public static SceneResponseModel FromSceneResult<T>(SceneResult<T> sceneResult, Guid? seedId)
{
return new SceneResponseModel
{
Result = sceneResult.Result,
MangleMap = sceneResult.MangleMap,
SeedId = seedId
};
}
}

View File

@@ -19,7 +19,7 @@ builder.Services.AddScoped<Microsoft.AspNetCore.Identity.IPasswordHasher<Bit.Cor
// Seeder services
builder.Services.AddSingleton<Bit.RustSDK.RustSdkService>();
builder.Services.AddScoped<Bit.Seeder.Factories.UserSeeder>();
builder.Services.AddScoped<IRecipeService, RecipeService>();
builder.Services.AddScoped<ISeedService, SeedService>();
builder.Services.AddScoped<MangleId>(_ => new MangleId());
builder.Services.AddScenes();
builder.Services.AddQueries();

View File

@@ -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
{
/// <summary>
/// Executes a scene with the given template name and arguments.
@@ -13,7 +14,7 @@ public interface IRecipeService
/// <returns>A tuple containing the result and optional seed ID for tracked entities</returns>
/// <exception cref="RecipeNotFoundException">Thrown when the scene template is not found</exception>
/// <exception cref="RecipeExecutionException">Thrown when there's an error executing the scene</exception>
(object? Result, Guid? SeedId) ExecuteRecipe(string templateName, JsonElement? arguments);
SceneResponseModel ExecuteScene(string templateName, JsonElement? arguments);
/// <summary>
/// Destroys data created by a scene using the seeded data ID.

View File

@@ -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<RecipeService> logger,
ILogger<SeedService> 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<object?> ExecuteSceneMethod(string templateName, JsonElement? arguments, string methodName)
{
try
{