1
0
mirror of https://github.com/bitwarden/server synced 2025-12-13 23:03:36 +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.Requests;
using Bit.SeederApi.Models.Response; using Bit.SeederApi.Models.Response;
using Xunit; using Xunit;
@@ -40,11 +40,12 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SeedResponseModel>(); var result = await response.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(result); Assert.NotNull(result);
Assert.NotEqual(Guid.Empty, result.SeedId); Assert.NotEqual(Guid.Empty, result.SeedId);
Assert.NotNull(result.Result); Assert.NotNull(result.MangleMap);
Assert.Null(result.Result);
} }
[Fact] [Fact]
@@ -83,7 +84,7 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
}); });
seedResponse.EnsureSuccessStatusCode(); seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SeedResponseModel>(); var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult); Assert.NotNull(seedResult);
var deleteResponse = await _client.DeleteAsync($"/seed/{seedResult.SeedId}"); var deleteResponse = await _client.DeleteAsync($"/seed/{seedResult.SeedId}");
@@ -117,7 +118,7 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
}); });
seedResponse.EnsureSuccessStatusCode(); seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SeedResponseModel>(); var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult); Assert.NotNull(seedResult);
Assert.NotNull(seedResult.SeedId); Assert.NotNull(seedResult.SeedId);
seedIds.Add(seedResult.SeedId.Value); seedIds.Add(seedResult.SeedId.Value);
@@ -149,7 +150,7 @@ public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, I
}); });
seedResponse.EnsureSuccessStatusCode(); seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SeedResponseModel>(); var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult); Assert.NotNull(seedResult);
// Try to delete with mix of valid and invalid IDs // 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 public interface IQuery
{ {
@@ -6,9 +6,9 @@ public interface IQuery
object Execute(object request); 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); Type IQuery.GetRequestType() => typeof(TRequest);
object IQuery.Execute(object request) => Execute((TRequest)request); object IQuery.Execute(object request) => Execute((TRequest)request);

View File

@@ -1,15 +1,30 @@
namespace Bit.Seeder; namespace Bit.Seeder;
public interface IScene public interface IScene
{ {
Type GetRequestType(); 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 public interface IScene<TRequest> : IScene where TRequest : class
{ {
SceneResult Seed(TRequest request); 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); 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 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;
@@ -9,7 +8,7 @@ namespace Bit.Seeder.Queries;
public class EmergencyAccessInviteQuery( public class EmergencyAccessInviteQuery(
DatabaseContext db, DatabaseContext db,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer) IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
: IQuery<EmergencyAccessInviteQuery.Request> : IQuery<EmergencyAccessInviteQuery.Request, IEnumerable<string>>
{ {
public class Request public class Request
{ {
@@ -17,7 +16,7 @@ public class EmergencyAccessInviteQuery(
public required string Email { get; set; } public required string Email { get; set; }
} }
public object Execute(Request request) public IEnumerable<string> Execute(Request request)
{ {
var invites = db.EmergencyAccesses var invites = db.EmergencyAccesses
.Where(ea => ea.Email == request.Email).ToList().Select(ea => .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; namespace Bit.Seeder;
public class SceneResult public class SceneResult : SceneResult<object?>
{ {
public required object Result { get; init; } public SceneResult(Dictionary<string, string?> mangleMap, Dictionary<string, List<Guid>> trackedEntities)
public Dictionary<string, List<Guid>> TrackedEntities { get; init; } = new(); : 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 System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Factories; using Bit.Seeder.Factories;
@@ -22,23 +21,19 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene
db.Add(user); db.Add(user);
db.SaveChanges(); db.SaveChanges();
return new SceneResult return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData
{
Result = userSeeder.GetMangleMap(user, new UserData
{ {
Email = request.Email, Email = request.Email,
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"), Id = user.Id,
Key = "seeded_key", Key = user.Key,
PublicKey = "seeded_public_key", PublicKey = user.PublicKey,
PrivateKey = "seeded_private_key", PrivateKey = user.PrivateKey,
ApiKey = "seeded_api_key", ApiKey = user.ApiKey,
Kdf = KdfType.PBKDF2_SHA256, Kdf = user.Kdf,
KdfIterations = 600_000, KdfIterations = user.KdfIterations,
}), }), trackedEntities: new Dictionary<string, List<Guid>>
TrackedEntities = new Dictionary<string, List<Guid>>
{ {
["User"] = [user.Id] ["User"] = [user.Id]
} });
};
} }
} }

View File

@@ -1,11 +1,11 @@
using Bit.SeederApi.Models.Requests; using Bit.SeederApi.Models.Requests;
using Bit.SeederApi.Services; using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers namespace Bit.SeederApi.Controllers;
{
[Route("query")] [Route("query")]
public class QueryController(ILogger<QueryController> logger, IRecipeService recipeService) public class QueryController(ILogger<QueryController> logger, ISeedService recipeService)
: Controller : Controller
{ {
[HttpPost] [HttpPost]
@@ -34,4 +34,3 @@ namespace Bit.SeederApi.Controllers
} }
} }
} }
}

View File

@@ -1,12 +1,11 @@
using Bit.SeederApi.Models.Requests; using Bit.SeederApi.Models.Requests;
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;
{
[Route("seed")] [Route("seed")]
public class SeedController(ILogger<SeedController> logger, IRecipeService recipeService) public class SeedController(ILogger<SeedController> logger, ISeedService recipeService)
: Controller : Controller
{ {
[HttpPost] [HttpPost]
@@ -16,13 +15,9 @@ namespace Bit.SeederApi.Controllers
try try
{ {
var (result, seedId) = recipeService.ExecuteRecipe(request.Template, request.Arguments); var response = recipeService.ExecuteScene(request.Template, request.Arguments);
return Json(new SeedResponseModel return Json(response);
{
SeedId = seedId,
Result = result,
});
} }
catch (RecipeNotFoundException ex) catch (RecipeNotFoundException ex)
{ {
@@ -136,4 +131,3 @@ namespace Bit.SeederApi.Controllers
return NoContent(); return NoContent();
} }
} }
}

View File

@@ -1,4 +1,4 @@
using System.Reflection; using System.Reflection;
using Bit.Seeder; using Bit.Seeder;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -12,11 +12,15 @@ public static class ServiceCollectionExtensions
/// </summary> /// </summary>
public static IServiceCollection AddScenes(this IServiceCollection services) 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 seederAssembly = Assembly.Load("Seeder");
var sceneTypes = seederAssembly.GetTypes() var sceneTypes = seederAssembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false } && .Where(t => t is { IsClass: true, IsAbstract: false } &&
t.GetInterfaces().Any(i => i.IsGenericType && t.GetInterfaces().Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition().Name == "IScene`1")); isIScene(i.GetGenericTypeDefinition())));
foreach (var sceneType in sceneTypes) foreach (var sceneType in sceneTypes)
{ {
@@ -33,11 +37,12 @@ public static class ServiceCollectionExtensions
/// </summary> /// </summary>
public static IServiceCollection AddQueries(this IServiceCollection services) public static IServiceCollection AddQueries(this IServiceCollection services)
{ {
var iQueryType = typeof(IQuery<,>);
var seederAssembly = Assembly.Load("Seeder"); var seederAssembly = Assembly.Load("Seeder");
var queryTypes = seederAssembly.GetTypes() var queryTypes = seederAssembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false } && .Where(t => t is { IsClass: true, IsAbstract: false } &&
t.GetInterfaces().Any(i => i.IsGenericType && t.GetInterfaces().Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition().Name == "IQuery`1")); i.GetGenericTypeDefinition() == iQueryType));
foreach (var queryType in queryTypes) 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 required Guid? SeedId { get; init; }
public object? Result { get; set; } 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 // Seeder services
builder.Services.AddSingleton<Bit.RustSDK.RustSdkService>(); builder.Services.AddSingleton<Bit.RustSDK.RustSdkService>();
builder.Services.AddScoped<Bit.Seeder.Factories.UserSeeder>(); 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.AddScoped<MangleId>(_ => new MangleId());
builder.Services.AddScenes(); builder.Services.AddScenes();
builder.Services.AddQueries(); builder.Services.AddQueries();

View File

@@ -1,9 +1,10 @@
using System.Text.Json; using System.Text.Json;
using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Models;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Services; namespace Bit.SeederApi.Services;
public interface IRecipeService public interface ISeedService
{ {
/// <summary> /// <summary>
/// Executes a scene with the given template name and arguments. /// 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> /// <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="RecipeNotFoundException">Thrown when the scene template is not found</exception>
/// <exception cref="RecipeExecutionException">Thrown when there's an error executing the scene</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> /// <summary>
/// Destroys data created by a scene using the seeded data ID. /// 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.Models;
using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder; using Bit.Seeder;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Services; namespace Bit.SeederApi.Services;
public class RecipeService( public class SeedService(
DatabaseContext databaseContext, DatabaseContext databaseContext,
ILogger<RecipeService> logger, ILogger<SeedService> logger,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
IUserRepository userRepository, IUserRepository userRepository,
IOrganizationRepository organizationRepository) IOrganizationRepository organizationRepository)
: IRecipeService : ISeedService
{ {
private static readonly JsonSerializerOptions _jsonOptions = new() private static readonly JsonSerializerOptions _jsonOptions = new()
{ {
@@ -25,13 +26,13 @@ public class RecipeService(
return databaseContext.SeededData.ToList(); 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) if (result.TrackedEntities.Count == 0)
{ {
return (Result: result.Result, SeedId: null); return SceneResponseModel.FromSceneResult(result, seedId: null);
} }
var seededData = new SeededData var seededData = new SeededData
@@ -48,7 +49,7 @@ 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: result.Result, SeedId: seededData.Id); return SceneResponseModel.FromSceneResult(result, seededData.Id);
} }
public object ExecuteQuery(string queryName, JsonElement? arguments) public object ExecuteQuery(string queryName, JsonElement? arguments)
@@ -166,7 +167,7 @@ public class RecipeService(
return new { SeedId = seedId, RecipeName = seededData.RecipeName }; 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 try
{ {