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

Use a header to track seeded data. This has benefits client side in simplicity and allows us to track entities added during a test, as long as they include the play id header.

This commit is contained in:
Matt Gibson
2025-11-10 18:17:41 -08:00
parent 0b22af53da
commit f2116734a2
20 changed files with 125 additions and 148 deletions

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bit.Core.Services;
@@ -11,3 +13,38 @@ public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService
return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment();
}
}
public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService
{
private PlayIdService Current
{
get
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
throw new InvalidOperationException("HttpContext is not available");
}
return httpContext.RequestServices.GetRequiredService<PlayIdService>();
}
}
public string? PlayId
{
get => Current.PlayId;
set => Current.PlayId = value;
}
public bool InPlay(out string playId)
{
if (hostEnvironment.IsDevelopment())
{
return Current.InPlay(out playId);
}
else
{
playId = string.Empty;
return false;
}
}
}

View File

@@ -1,12 +0,0 @@
namespace Bit.Infrastructure.EntityFramework.Models;
public class SeededData
{
public Guid Id { get; set; }
public required string RecipeName { get; set; }
/// <summary>
/// JSON blob containing all
/// </summary>
public required string Data { get; set; }
public DateTime CreationDate { get; set; }
}

View File

@@ -90,7 +90,6 @@ public class DatabaseContext : DbContext
public DbSet<OrganizationInstallation> OrganizationInstallations { get; set; }
public DbSet<OrganizationReport> OrganizationReports { get; set; }
public DbSet<OrganizationApplication> OrganizationApplications { get; set; }
public DbSet<SeededData> SeededData { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@@ -5,7 +5,7 @@ namespace Bit.SharedWeb.Utilities;
public sealed class PlayIdMiddleware(RequestDelegate next)
{
public Task Invoke(HttpContext context, IPlayIdService playIdService)
public Task Invoke(HttpContext context, PlayIdService playIdService)
{
if (context.Request.Headers.TryGetValue("x-play-id", out var playId))
{

View File

@@ -119,8 +119,11 @@ public static class ServiceCollectionExtensions
}
// Include PlayIdService for tracking Play Ids in repositories
services.AddScoped<IPlayIdService, PlayIdService>();
// We need the http context accessor to use the Singleton version, which pulls from the scoped version
services.AddHttpContextAccessor();
services.AddSingleton<IPlayIdService, PlayIdSingletonService>();
services.AddScoped<PlayIdService>();
return provider;
}

View File

@@ -5,8 +5,8 @@ CREATE TABLE [dbo].[PlayData] (
[OrganizationId] UNIQUEIDENTIFIER NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_PlayData] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PlayData_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
CONSTRAINT [FK_PlayData_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_PlayData_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_PlayData_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
CONSTRAINT [CK_PlayData_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL))
);

View File

@@ -1,6 +0,0 @@
CREATE TABLE [dbo].[SeededData] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[RecipeName] NVARCHAR (MAX) NOT NULL,
[Data] NVARCHAR (MAX) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
);

View File

@@ -1,10 +0,0 @@
IF OBJECT_ID('dbo.SeededData') IS NULL
BEGIN
CREATE TABLE [dbo].[SeededData] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[RecipeName] NVARCHAR (MAX) NOT NULL,
[Data] NVARCHAR (MAX) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
);
END
GO

View File

@@ -8,8 +8,8 @@ BEGIN
[OrganizationId] UNIQUEIDENTIFIER NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_PlayData] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PlayData_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
CONSTRAINT [FK_PlayData_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_PlayData_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_PlayData_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
CONSTRAINT [CK_PlayData_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL))
);

View File

@@ -1,7 +1,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Seeder.Factories;

View File

@@ -1,6 +1,6 @@
using Bit.Core.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.RustSDK;
using Microsoft.AspNetCore.Identity;

View File

@@ -3,7 +3,7 @@
public interface IScene
{
Type GetRequestType();
SceneResult<object?> Seed(object request);
Task<SceneResult<object?>> SeedAsync(object request);
}
/// <summary>
@@ -12,19 +12,19 @@ public interface IScene
/// <typeparam name="TRequest"></typeparam>
public interface IScene<TRequest> : IScene where TRequest : class
{
SceneResult Seed(TRequest request);
Task<SceneResult> SeedAsync(TRequest request);
Type IScene.GetRequestType() => typeof(TRequest);
SceneResult<object?> IScene.Seed(object request)
async Task<SceneResult<object?>> IScene.SeedAsync(object request)
{
var result = Seed((TRequest)request);
return new SceneResult(mangleMap: result.MangleMap, trackedEntities: result.TrackedEntities);
var result = await SeedAsync((TRequest)request);
return new SceneResult(mangleMap: result.MangleMap);
}
}
public interface IScene<TRequest, TResult> : IScene where TRequest : class where TResult : class
{
SceneResult<TResult> Seed(TRequest request);
Task<SceneResult<TResult>> SeedAsync(TRequest request);
Type IScene.GetRequestType() => typeof(TRequest);
SceneResult<object?> IScene.Seed(object request) => (SceneResult<object?>)Seed((TRequest)request);
async Task<SceneResult<object?>> IScene.SeedAsync(object request) => (SceneResult<object?>)await SeedAsync((TRequest)request);
}

View File

@@ -1,4 +1,4 @@
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;

View File

@@ -2,21 +2,19 @@
public class SceneResult : SceneResult<object?>
{
public SceneResult(Dictionary<string, string?> mangleMap, Dictionary<string, List<Guid>> trackedEntities)
: base(result: null, mangleMap: mangleMap, trackedEntities: trackedEntities) { }
public SceneResult(Dictionary<string, string?> mangleMap)
: base(result: null, mangleMap: mangleMap) { }
}
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)
public SceneResult(TResult result, Dictionary<string, string?> mangleMap)
{
Result = result;
MangleMap = mangleMap;
TrackedEntities = trackedEntities;
}
public static explicit operator SceneResult<object?>(SceneResult<TResult> v)
@@ -25,11 +23,11 @@ public class SceneResult<TResult>
if (result is null)
{
return new SceneResult<object?>(result: null, mangleMap: v.MangleMap, trackedEntities: v.TrackedEntities);
return new SceneResult<object?>(result: null, mangleMap: v.MangleMap);
}
else
{
return new SceneResult<object?>(result: result, mangleMap: v.MangleMap, trackedEntities: v.TrackedEntities);
return new SceneResult<object?>(result: result, mangleMap: v.MangleMap);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Core.Repositories;
using Bit.Seeder.Factories;
namespace Bit.Seeder.Scenes;
public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene<SingleUserScene.Request>
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request>
{
public class Request
{
@@ -14,12 +14,11 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene
public bool Premium { get; set; } = false;
}
public SceneResult Seed(Request request)
public async Task<SceneResult> SeedAsync(Request request)
{
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
db.Add(user);
db.SaveChanges();
await userRepository.CreateAsync(user);
return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData
{
@@ -31,9 +30,6 @@ public class SingleUserScene(DatabaseContext db, UserSeeder userSeeder) : IScene
ApiKey = user.ApiKey,
Kdf = user.Kdf,
KdfIterations = user.KdfIterations,
}), trackedEntities: new Dictionary<string, List<Guid>>
{
["User"] = [user.Id]
});
}));
}
}

View File

@@ -6,17 +6,18 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
[Route("seed")]
public class SeedController(ILogger<SeedController> logger, ISceneService sceneService)
public class SeedController(ILogger<SeedController> logger, ISceneService sceneService, IServiceProvider serviceProvider)
: Controller
{
[HttpPost]
public IActionResult Seed([FromBody] SeedRequestModel request)
public async Task<IActionResult> Seed([FromBody] SeedRequestModel request)
{
logger.LogInformation("Received seed request {Provider}", serviceProvider.GetType().FullName);
logger.LogInformation("Seeding with template: {Template}", request.Template);
try
{
SceneResponseModel response = sceneService.ExecuteScene(request.Template, request.Arguments);
SceneResponseModel response = await sceneService.ExecuteScene(request.Template, request.Arguments);
return Json(response);
}
@@ -36,24 +37,24 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
}
[HttpDelete("batch")]
public async Task<IActionResult> DeleteBatch([FromBody] List<Guid> seedIds)
public async Task<IActionResult> DeleteBatch([FromBody] List<string> playIds)
{
logger.LogInformation("Deleting batch of seeded data with IDs: {SeedIds}", string.Join(", ", seedIds));
logger.LogInformation("Deleting batch of seeded data with IDs: {PlayIds}", string.Join(", ", playIds));
var aggregateException = new AggregateException();
await Task.Run(async () =>
{
foreach (var seedId in seedIds)
foreach (var playId in playIds)
{
try
{
await sceneService.DestroyScene(seedId);
await sceneService.DestroyScene(playId);
}
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId);
logger.LogError(ex, "Error deleting seeded data: {SeedId}", playId);
}
}
});
@@ -72,20 +73,20 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
});
}
[HttpDelete("{seedId}")]
public async Task<IActionResult> Delete([FromRoute] Guid seedId)
[HttpDelete("{playId}")]
public async Task<IActionResult> Delete([FromRoute] string playId)
{
logger.LogInformation("Deleting seeded data with ID: {SeedId}", seedId);
logger.LogInformation("Deleting seeded data with ID: {PlayId}", playId);
try
{
var result = await sceneService.DestroyScene(seedId);
var result = await sceneService.DestroyScene(playId);
return Json(result);
}
catch (SceneExecutionException ex)
{
logger.LogError(ex, "Error deleting seeded data: {SeedId}", seedId);
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
return BadRequest(new
{
Error = ex.Message,
@@ -101,25 +102,23 @@ public class SeedController(ILogger<SeedController> logger, ISceneService sceneS
logger.LogInformation("Deleting all seeded data");
// Pull all Seeded Data ids
var seededData = sceneService.GetAllSeededData();
var playIds = sceneService.GetAllPlayIds();
var aggregateException = new AggregateException();
await Task.Run(async () =>
foreach (var playId in playIds)
{
foreach (var sd in seededData)
try
{
try
{
await sceneService.DestroyScene(sd.Id);
}
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
logger.LogError(ex, "Error deleting seeded data: {SeedId}", sd.Id);
}
await sceneService.DestroyScene(playId);
}
});
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
}
}
if (aggregateException.InnerExceptions.Count > 0)
{

View File

@@ -4,17 +4,15 @@ namespace Bit.SeederApi.Models.Response;
public class SceneResponseModel
{
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)
public static SceneResponseModel FromSceneResult<T>(SceneResult<T> sceneResult)
{
return new SceneResponseModel
{
Result = sceneResult.Result,
MangleMap = sceneResult.MangleMap,
SeedId = seedId
};
}
}

View File

@@ -6,6 +6,7 @@ using Bit.SharedWeb.Utilities;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHttpContextAccessor();
var globalSettings = builder.Services.AddGlobalSettingsServices(builder.Configuration, builder.Environment);
@@ -26,6 +27,9 @@ builder.Services.AddQueries();
var app = builder.Build();
// Add PlayIdMiddleware services
app.UseMiddleware<PlayIdMiddleware>();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{

View File

@@ -1,5 +1,4 @@
using System.Text.Json;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Services;
@@ -14,14 +13,14 @@ public interface ISceneService
/// <returns>A tuple containing the result and optional seed ID for tracked entities</returns>
/// <exception cref="SceneNotFoundException">Thrown when the scene template is not found</exception>
/// <exception cref="SceneExecutionException">Thrown when there's an error executing the scene</exception>
SceneResponseModel ExecuteScene(string templateName, JsonElement? arguments);
Task<SceneResponseModel> ExecuteScene(string templateName, JsonElement? arguments);
/// <summary>
/// Destroys data created by a scene using the seeded data ID.
/// </summary>
/// <param name="seedId">The ID of the seeded data to destroy</param>
/// <param name="playId">The ID of the seeded data to destroy</param>
/// <returns>The result of the destroy operation</returns>
/// <exception cref="SceneExecutionException">Thrown when there's an error destroying the seeded data</exception>
Task<object?> DestroyScene(Guid seedId);
List<SeededData> GetAllSeededData();
Task<object?> DestroyScene(string playId);
List<string> GetAllPlayIds();
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder;
using Bit.SeederApi.Models.Response;
@@ -12,6 +11,7 @@ public class SceneService(
ILogger<SceneService> logger,
IServiceProvider serviceProvider,
IUserRepository userRepository,
IPlayDataRepository playDataRepository,
IOrganizationRepository organizationRepository)
: ISceneService
{
@@ -21,35 +21,18 @@ public class SceneService(
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public List<SeededData> GetAllSeededData()
public List<string> GetAllPlayIds()
{
return databaseContext.SeededData.ToList();
return [.. databaseContext.PlayData
.Select(pd => pd.PlayId)
.Distinct()];
}
public SceneResponseModel ExecuteScene(string templateName, JsonElement? arguments)
public async Task<SceneResponseModel> ExecuteScene(string templateName, JsonElement? arguments)
{
var result = ExecuteSceneMethod(templateName, arguments, "Seed");
var result = await ExecuteSceneMethod(templateName, arguments, "Seed");
if (result.TrackedEntities.Count == 0)
{
return SceneResponseModel.FromSceneResult(result, seedId: null);
}
var seededData = new SeededData
{
Id = Guid.NewGuid(),
RecipeName = templateName,
Data = JsonSerializer.Serialize(result.TrackedEntities),
CreationDate = DateTime.UtcNow
};
databaseContext.Add(seededData);
databaseContext.SaveChanges();
logger.LogInformation("Saved seeded data with ID {SeedId} for scene {SceneName}",
seededData.Id, templateName);
return SceneResponseModel.FromSceneResult(result, seededData.Id);
return SceneResponseModel.FromSceneResult(result);
}
public object ExecuteQuery(string queryName, JsonElement? arguments)
@@ -113,31 +96,23 @@ public class SceneService(
}
}
public async Task<object?> DestroyScene(Guid seedId)
public async Task<object?> DestroyScene(string playId)
{
var seededData = databaseContext.SeededData.FirstOrDefault(s => s.Id == seedId);
if (seededData == null)
{
logger.LogInformation("No seeded data found with ID {SeedId}, skipping", seedId);
return null;
}
// Note, delete cascade will remove PlayData entries
var trackedEntities = JsonSerializer.Deserialize<Dictionary<string, List<Guid>>>(seededData.Data);
if (trackedEntities == null)
{
throw new SceneExecutionException($"Failed to deserialize tracked entities for seed ID {seedId}");
}
var playData = await playDataRepository.GetByPlayIdAsync(playId);
var userIds = playData.Select(pd => pd.UserId).Distinct().ToList();
var organizationIds = playData.Select(pd => pd.OrganizationId).Distinct().ToList();
// Delete in reverse order to respect foreign key constraints
if (trackedEntities.TryGetValue("User", out var userIds))
// Delete Users before Oraganizations to respect foreign key constraints
if (userIds.Count > 0)
{
var users = databaseContext.Users.Where(u => userIds.Contains(u.Id));
await userRepository.DeleteManyAsync(users);
}
if (trackedEntities.TryGetValue("Organization", out var orgIds))
if (organizationIds.Count > 0)
{
var organizations = databaseContext.Organizations.Where(o => orgIds.Contains(o.Id));
var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id));
var aggregateException = new AggregateException();
foreach (var org in organizations)
{
@@ -153,21 +128,18 @@ public class SceneService(
if (aggregateException.InnerExceptions.Count > 0)
{
throw new SceneExecutionException(
$"One or more errors occurred while deleting organizations for seed ID {seedId}",
$"One or more errors occurred while deleting organizations for seed ID {playId}",
aggregateException);
}
}
databaseContext.Remove(seededData);
databaseContext.SaveChanges();
logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}",
playId);
logger.LogInformation("Successfully destroyed seeded data with ID {SeedId} for scene {SceneName}",
seedId, seededData.RecipeName);
return new { SeedId = seedId, SceneName = seededData.RecipeName };
return new { PlayId = playId };
}
private SceneResult<object?> ExecuteSceneMethod(string templateName, JsonElement? arguments, string methodName)
private async Task<SceneResult<object?>> ExecuteSceneMethod(string templateName, JsonElement? arguments, string methodName)
{
try
{
@@ -214,7 +186,7 @@ public class SceneService(
}
}
var result = scene.Seed(requestModel);
var result = await scene.SeedAsync(requestModel);
logger.LogInformation("Successfully executed {MethodName} on scene: {TemplateName}", methodName, templateName);
return result;