1
0
mirror of https://github.com/bitwarden/server synced 2025-12-14 15:23:42 +00:00

Add queries, rename seed to scene

This commit is contained in:
Hinton
2025-10-18 15:55:48 -04:00
parent fd41332e4c
commit 1daf9ad892
13 changed files with 298 additions and 187 deletions

15
util/Seeder/IQuery.cs Normal file
View File

@@ -0,0 +1,15 @@
namespace Bit.Seeder;
public interface IQuery
{
Type GetRequestType();
object Execute(object request);
}
public interface IQuery<TRequest> : IQuery where TRequest : class
{
object Execute(TRequest request);
Type IQuery.GetRequestType() => typeof(TRequest);
object IQuery.Execute(object request) => Execute((TRequest)request);
}

View File

@@ -3,13 +3,13 @@ namespace Bit.Seeder;
public interface IScene public interface IScene
{ {
Type GetRequestType(); Type GetRequestType();
RecipeResult Seed(object request); SceneResult Seed(object request);
} }
public interface IScene<TRequest> : IScene where TRequest : class public interface IScene<TRequest> : IScene where TRequest : class
{ {
RecipeResult Seed(TRequest request); SceneResult Seed(TRequest request);
Type IScene.GetRequestType() => typeof(TRequest); Type IScene.GetRequestType() => typeof(TRequest);
RecipeResult IScene.Seed(object request) => Seed((TRequest)request); SceneResult IScene.Seed(object request) => Seed((TRequest)request);
} }

View File

@@ -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.Auth.Models.Business.Tokenables;
using Bit.Core.Tokens; using Bit.Core.Tokens;
using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories;
namespace Bit.Seeder.Recipes; namespace Bit.Seeder.Queries;
public class EmergencyAccessInviteRecipe( public class EmergencyAccessInviteQuery(
DatabaseContext db, DatabaseContext db,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer) IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
: IQuery<EmergencyAccessInviteQuery.Request>
{ {
public RecipeResult Seed(string email) public class Request
{
[Required]
public required string Email { get; set; }
}
public object Execute(Request request)
{ {
var invites = db.EmergencyAccesses 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( var token = dataProtectorTokenizer.Protect(
new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1) 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 $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}";
}); });
return new RecipeResult return invites;
{
Result = invites,
};
} }
} }

View File

@@ -7,9 +7,8 @@ namespace Bit.Seeder.Recipes;
public class OrganizationWithUsersRecipe(DatabaseContext db) 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 organization = OrganizationSeeder.CreateEnterprise(name, domain, users);
var user = UserSeeder.CreateUserNoMangle($"admin@{domain}"); var user = UserSeeder.CreateUserNoMangle($"admin@{domain}");
var orgUser = organization.CreateOrganizationUser(user); var orgUser = organization.CreateOrganizationUser(user);
@@ -33,14 +32,6 @@ public class OrganizationWithUsersRecipe(DatabaseContext db)
db.BulkCopy(additionalUsers); db.BulkCopy(additionalUsers);
db.BulkCopy(additionalOrgUsers); db.BulkCopy(additionalOrgUsers);
return new RecipeResult return organization.Id;
{
Result = organization.Id,
TrackedEntities = new Dictionary<string, List<Guid>>
{
["Organization"] = [organization.Id],
["User"] = [user.Id, .. additionalUsers.Select(u => u.Id)]
}
};
} }
} }

View File

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

View File

@@ -15,14 +15,14 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene
public bool Premium { get; set; } = false; 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); var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
db.Add(user); db.Add(user);
db.SaveChanges(); db.SaveChanges();
return new RecipeResult return new SceneResult
{ {
Result = userSeeder.GetMangleMap(user, new UserData Result = userSeeder.GetMangleMap(user, new UserData
{ {

View File

@@ -1,172 +1,165 @@
using System.ComponentModel.DataAnnotations; using Bit.SeederApi.Models.Requests;
using System.Text.Json; using Bit.SeederApi.Models.Response;
using Bit.SeederApi.Services; using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers; namespace Bit.SeederApi.Controllers
public class SeedRequestModel
{ {
[Required] [Route("")]
public required string Template { get; set; } public class SeedController(ILogger<SeedController> logger, IRecipeService recipeService)
public JsonElement? Arguments { get; set; } : Controller
}
[Route("")]
public class SeedController(ILogger<SeedController> logger, IRecipeService recipeService)
: Controller
{
[HttpPost("/seed")]
public IActionResult Seed([FromBody] SeedRequestModel request)
{ {
logger.LogInformation("Seeding with template: {Template}", request.Template); [HttpPost("/query")]
public IActionResult Query([FromBody] SeedRequestModel request)
try
{ {
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<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 return Ok(new
{ {
Message = "Seed completed successfully", Message = "Batch delete completed successfully"
request.Template,
Result = result,
SeedId = seedId
}); });
} }
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")] [HttpDelete("/seed/{seedId}")]
public async Task<IActionResult> DeleteBatch([FromBody] List<Guid> seedIds) public async Task<IActionResult> Delete([FromRoute] Guid seedId)
{
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) logger.LogInformation("Deleting seeded data with ID: {SeedId}", seedId);
try
{ {
try var result = await recipeService.DestroyRecipe(seedId);
{
await recipeService.DestroyRecipe(seedId); return Json(result);
}
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId);
}
} }
}); catch (RecipeExecutionException ex)
if (aggregateException.InnerExceptions.Count > 0)
{
return BadRequest(new
{ {
Error = "One or more errors occurred while deleting seeded data", logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId);
Details = aggregateException.InnerExceptions.Select(e => e.Message).ToList() return BadRequest(new
});
}
return Ok(new
{
Message = "Batch delete completed successfully"
});
}
[HttpDelete("/seed/{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 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<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); Error = ex.Message,
} Details = ex.InnerException?.Message
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 Ok(new
[HttpDelete("/seed")]
public async Task<IActionResult> 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();
}
} }
} }

View File

@@ -21,7 +21,28 @@ public static class ServiceCollectionExtensions
foreach (var sceneType in sceneTypes) foreach (var sceneType in sceneTypes)
{ {
services.TryAddScoped(sceneType); 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;
}
/// <summary>
/// Dynamically registers all query types that implement IQuery<TRequest> from the Seeder assembly.
/// Queries are registered as keyed scoped services using their class name as the key.
/// </summary>
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; return services;

View File

@@ -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; }
}

View File

@@ -0,0 +1,7 @@
namespace Bit.SeederApi.Models.Response;
public class SeedResponseModel
{
public Guid? SeedId { get; set; }
public object? Result { get; set; }
}

View File

@@ -22,6 +22,7 @@ builder.Services.AddScoped<Bit.Seeder.Factories.UserSeeder>();
builder.Services.AddScoped<IRecipeService, RecipeService>(); builder.Services.AddScoped<IRecipeService, RecipeService>();
builder.Services.AddScoped<MangleId>(_ => new MangleId()); builder.Services.AddScoped<MangleId>(_ => new MangleId());
builder.Services.AddScenes(); builder.Services.AddScenes();
builder.Services.AddQueries();
var app = builder.Build(); var app = builder.Build();

View File

@@ -23,4 +23,15 @@ public interface IRecipeService
/// <exception cref="RecipeExecutionException">Thrown when there's an error destroying the seeded data</exception> /// <exception cref="RecipeExecutionException">Thrown when there's an error destroying the seeded data</exception>
Task<object?> DestroyRecipe(Guid seedId); Task<object?> DestroyRecipe(Guid seedId);
List<SeededData> GetAllSeededData(); List<SeededData> GetAllSeededData();
/// <summary>
/// Executes a query with the given query name and arguments.
/// Queries are read-only and do not track entities or create seed IDs.
/// </summary>
/// <param name="queryName">The name of the query (e.g., "EmergencyAccessInviteQuery")</param>
/// <param name="arguments">Optional JSON arguments to pass to the query's Execute method</param>
/// <returns>The result of the query execution</returns>
/// <exception cref="RecipeNotFoundException">Thrown when the query is not found</exception>
/// <exception cref="RecipeExecutionException">Thrown when there's an error executing the query</exception>
object ExecuteQuery(string queryName, JsonElement? arguments);
} }

View File

@@ -29,21 +29,16 @@ public class RecipeService(
{ {
var result = ExecuteRecipeMethod(templateName, arguments, "Seed"); var result = ExecuteRecipeMethod(templateName, arguments, "Seed");
if (result is not RecipeResult recipeResult) if (result.TrackedEntities.Count == 0)
{ {
return (Result: result, SeedId: null); return (Result: result.Result, SeedId: null);
}
if (recipeResult.TrackedEntities.Count == 0)
{
return (Result: recipeResult.Result, SeedId: null);
} }
var seededData = new SeededData var seededData = new SeededData
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
RecipeName = templateName, RecipeName = templateName,
Data = JsonSerializer.Serialize(recipeResult.TrackedEntities), Data = JsonSerializer.Serialize(result.TrackedEntities),
CreationDate = DateTime.UtcNow CreationDate = DateTime.UtcNow
}; };
@@ -53,7 +48,68 @@ public class RecipeService(
logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}", logger.LogInformation("Saved seeded data with ID {SeedId} for scene {RecipeName}",
seededData.Id, templateName); 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<IQuery>(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<object?> DestroyRecipe(Guid seedId) public async Task<object?> DestroyRecipe(Guid seedId)
@@ -110,7 +166,7 @@ public class RecipeService(
return new { SeedId = seedId, RecipeName = seededData.RecipeName }; 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 try
{ {