mirror of
https://github.com/bitwarden/server
synced 2026-01-18 00:13:19 +00:00
[PM-22263] [PM-29849] Initial PoC of seeder API (#6424)
We want to reduce the amount of business critical test data in the company. One way of doing that is to generate test data on demand prior to client side testing. Clients will request a scene to be set up with a JSON body set of options, specific to a given scene. Successful seed requests will be responded to with a mangleMap which maps magic strings present in the request to the mangled, non-colliding versions inserted into the database. This way, the server is solely responsible for understanding uniqueness requirements in the database. scenes also are able to return custom data, depending on the scene. For example, user creation would benefit from a return value of the userId for further test setup on the client side. Clients will indicate they are running tests by including a unique header, x-play-id which specifies a unique testing context. The server uses this PlayId as the seed for any mangling that occurs. This allows the client to decide it will reuse a given PlayId if the test context builds on top of previously executed tests. When a given context is no longer needed, the API user will delete all test data associated with the PlayId by calling a delete endpoint. --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
90
util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql
Normal file
90
util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql
Normal file
@@ -0,0 +1,90 @@
|
||||
-- Create PlayItem table
|
||||
IF OBJECT_ID('dbo.PlayItem') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE [dbo].[PlayItem] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[PlayId] NVARCHAR (256) NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId]
|
||||
ON [dbo].[PlayItem]([PlayId] ASC);
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId]
|
||||
ON [dbo].[PlayItem]([UserId] ASC);
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId]
|
||||
ON [dbo].[PlayItem]([OrganizationId] ASC);
|
||||
END
|
||||
GO
|
||||
|
||||
-- Create PlayItem_Create stored procedure
|
||||
CREATE OR ALTER PROCEDURE [dbo].[PlayItem_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@PlayId NVARCHAR(256),
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CreationDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[PlayItem]
|
||||
(
|
||||
[Id],
|
||||
[PlayId],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[CreationDate]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@PlayId,
|
||||
@UserId,
|
||||
@OrganizationId,
|
||||
@CreationDate
|
||||
)
|
||||
END
|
||||
GO
|
||||
|
||||
-- Create PlayItem_ReadByPlayId stored procedure
|
||||
CREATE OR ALTER PROCEDURE [dbo].[PlayItem_ReadByPlayId]
|
||||
@PlayId NVARCHAR(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
[Id],
|
||||
[PlayId],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[CreationDate]
|
||||
FROM
|
||||
[dbo].[PlayItem]
|
||||
WHERE
|
||||
[PlayId] = @PlayId
|
||||
END
|
||||
GO
|
||||
|
||||
-- Create PlayItem_DeleteByPlayId stored procedure
|
||||
CREATE OR ALTER PROCEDURE [dbo].[PlayItem_DeleteByPlayId]
|
||||
@PlayId NVARCHAR(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[PlayItem]
|
||||
WHERE
|
||||
[PlayId] = @PlayId
|
||||
END
|
||||
GO
|
||||
3502
util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs
generated
Normal file
3502
util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.MySqlMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class CreatePlayItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlayItem",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
PlayId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
UserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
OrganizationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
CreationDate = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlayItem", x => x.Id);
|
||||
table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayItem_Organization_OrganizationId",
|
||||
column: x => x.OrganizationId,
|
||||
principalTable: "Organization",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayItem_User_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "User",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_OrganizationId",
|
||||
table: "PlayItem",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_PlayId",
|
||||
table: "PlayItem",
|
||||
column: "PlayId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_UserId",
|
||||
table: "PlayItem",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlayItem");
|
||||
}
|
||||
}
|
||||
@@ -282,71 +282,6 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.ToTable("Organization", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId", "Type")
|
||||
.IsUnique()
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("OrganizationIntegration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("EventType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Filters")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid>("OrganizationIntegrationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Template")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationIntegrationId");
|
||||
|
||||
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -626,8 +561,8 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Property<byte>("Type")
|
||||
.HasColumnType("tinyint unsigned");
|
||||
|
||||
b.Property<int>("WaitTimeDays")
|
||||
.HasColumnType("int");
|
||||
b.Property<short>("WaitTimeDays")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -1015,6 +950,71 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.ToTable("OrganizationApplication", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId", "Type")
|
||||
.IsUnique()
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("OrganizationIntegration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("EventType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Filters")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid>("OrganizationIntegrationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Template")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationIntegrationId");
|
||||
|
||||
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1627,6 +1627,42 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.ToTable("OrganizationUser", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("PlayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("PlayId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("PlayItem", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2607,28 +2643,6 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.HasDiscriminator().HasValue("user_service_account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationIntegrationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OrganizationIntegration");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
@@ -2807,6 +2821,28 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationIntegrationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OrganizationIntegration");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
@@ -3003,6 +3039,23 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Organization");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
|
||||
3508
util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs
generated
Normal file
3508
util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.PostgresMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class CreatePlayItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlayItem",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PlayId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlayItem", x => x.Id);
|
||||
table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayItem_Organization_OrganizationId",
|
||||
column: x => x.OrganizationId,
|
||||
principalTable: "Organization",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayItem_User_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "User",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_OrganizationId",
|
||||
table: "PlayItem",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_PlayId",
|
||||
table: "PlayItem",
|
||||
column: "PlayId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_UserId",
|
||||
table: "PlayItem",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlayItem");
|
||||
}
|
||||
}
|
||||
@@ -285,71 +285,6 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.ToTable("Organization", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId", "Type")
|
||||
.IsUnique()
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("OrganizationIntegration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("EventType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Filters")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("OrganizationIntegrationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Template")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationIntegrationId");
|
||||
|
||||
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -629,8 +564,8 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.Property<byte>("Type")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.Property<int>("WaitTimeDays")
|
||||
.HasColumnType("integer");
|
||||
b.Property<short>("WaitTimeDays")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -1020,6 +955,71 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.ToTable("OrganizationApplication", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId", "Type")
|
||||
.IsUnique()
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("OrganizationIntegration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("EventType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Filters")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("OrganizationIntegrationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Template")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationIntegrationId");
|
||||
|
||||
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1632,6 +1632,42 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.ToTable("OrganizationUser", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PlayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("PlayId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("PlayItem", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2613,28 +2649,6 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.HasDiscriminator().HasValue("user_service_account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationIntegrationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OrganizationIntegration");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
@@ -2813,6 +2827,28 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationIntegrationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OrganizationIntegration");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
@@ -3009,6 +3045,23 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Organization");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- This is a work around because this file is compiled by the PreBuild event below, and won't
|
||||
always be detected -->
|
||||
<Compile Remove="NativeMethods.g.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="NativeMethods.g.cs" />
|
||||
|
||||
<Content Include="rust/target/release/libsdk*.dylib">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<PackageCopyToOutput>true</PackageCopyToOutput>
|
||||
@@ -18,23 +26,36 @@
|
||||
<Content Include="./rust/target/release/libsdk*.so">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<PackageCopyToOutput>true</PackageCopyToOutput>
|
||||
<Link>runtimes/linux-x64/native/libsdk.dylib</Link>
|
||||
<Link>runtimes/linux-x64/native/libsdk.so</Link>
|
||||
</Content>
|
||||
<Content Include="./rust/target/release/libsdk*.dll">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<PackageCopyToOutput>true</PackageCopyToOutput>
|
||||
<Link>runtimes/windows-x64/native/libsdk.dylib</Link>
|
||||
<Link>runtimes/windows-x64/native/libsdk.dll</Link>
|
||||
</Content>
|
||||
|
||||
<!-- This is a work around because this file is compiled by the PreBuild event below, and won't
|
||||
always be detected -->
|
||||
<Compile Remove="NativeMethods.g.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
|
||||
<Exec Command="cargo build --release" WorkingDirectory="$(ProjectDir)/rust" />
|
||||
<ItemGroup>
|
||||
<Compile Include="NativeMethods.g.cs" />
|
||||
|
||||
<!-- Include native libraries after they've been built -->
|
||||
<Content Include="rust/target/release/libsdk*.dylib">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<PackageCopyToOutput>true</PackageCopyToOutput>
|
||||
<Link>runtimes/osx-arm64/native/libsdk.dylib</Link>
|
||||
</Content>
|
||||
<Content Include="./rust/target/release/libsdk*.so">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<PackageCopyToOutput>true</PackageCopyToOutput>
|
||||
<Link>runtimes/linux-x64/native/libsdk.so</Link>
|
||||
</Content>
|
||||
<Content Include="./rust/target/release/libsdk*.dll">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<PackageCopyToOutput>true</PackageCopyToOutput>
|
||||
<Link>runtimes/windows-x64/native/libsdk.dll</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
|
||||
2
util/RustSdk/rust-toolchain.toml
Normal file
2
util/RustSdk/rust-toolchain.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.87.0"
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.RustSDK;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
public class UserSeeder
|
||||
public struct UserData
|
||||
{
|
||||
public static User CreateUser(string email)
|
||||
public string Email;
|
||||
public Guid Id;
|
||||
public string? Key;
|
||||
public string? PublicKey;
|
||||
public string? PrivateKey;
|
||||
public string? ApiKey;
|
||||
public KdfType Kdf;
|
||||
public int KdfIterations;
|
||||
}
|
||||
|
||||
public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Entities.User> passwordHasher, MangleId mangleId)
|
||||
{
|
||||
private string MangleEmail(string email)
|
||||
{
|
||||
return $"{mangleId}+{email}";
|
||||
}
|
||||
|
||||
public User CreateUser(string email, bool emailVerified = false, bool premium = false)
|
||||
{
|
||||
email = MangleEmail(email);
|
||||
var keys = sdkService.GenerateUserKeys(email, "asdfasdfasdf");
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Email = email,
|
||||
EmailVerified = emailVerified,
|
||||
MasterPassword = null,
|
||||
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
|
||||
Key = keys.EncryptedUserKey,
|
||||
PublicKey = keys.PublicKey,
|
||||
PrivateKey = keys.PrivateKey,
|
||||
Premium = premium,
|
||||
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
|
||||
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 5_000,
|
||||
};
|
||||
|
||||
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public static User CreateUserNoMangle(string email)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
@@ -25,28 +70,35 @@ public class UserSeeder
|
||||
};
|
||||
}
|
||||
|
||||
public static (User user, string userKey) CreateSdkUser(IPasswordHasher<Bit.Core.Entities.User> passwordHasher, string email)
|
||||
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)
|
||||
{
|
||||
var nativeService = RustSdkServiceFactory.CreateSingleton();
|
||||
var keys = nativeService.GenerateUserKeys(email, "asdfasdfasdf");
|
||||
|
||||
var user = new User
|
||||
var mangleMap = new Dictionary<string, string?>
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = email,
|
||||
MasterPassword = null,
|
||||
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
|
||||
Key = keys.EncryptedUserKey,
|
||||
PublicKey = keys.PublicKey,
|
||||
PrivateKey = keys.PrivateKey,
|
||||
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
|
||||
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 5_000,
|
||||
{ expectedUserData.Email, MangleEmail(expectedUserData.Email) },
|
||||
{ expectedUserData.Id.ToString(), user.Id.ToString() },
|
||||
{ expectedUserData.Kdf.ToString(), user.Kdf.ToString() },
|
||||
{ expectedUserData.KdfIterations.ToString(), user.KdfIterations.ToString() }
|
||||
};
|
||||
if (expectedUserData.Key != null)
|
||||
{
|
||||
mangleMap[expectedUserData.Key] = user.Key;
|
||||
}
|
||||
|
||||
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
|
||||
if (expectedUserData.PublicKey != null)
|
||||
{
|
||||
mangleMap[expectedUserData.PublicKey] = user.PublicKey;
|
||||
}
|
||||
|
||||
return (user, keys.Key);
|
||||
if (expectedUserData.PrivateKey != null)
|
||||
{
|
||||
mangleMap[expectedUserData.PrivateKey] = user.PrivateKey;
|
||||
}
|
||||
|
||||
if (expectedUserData.ApiKey != null)
|
||||
{
|
||||
mangleMap[expectedUserData.ApiKey] = user.ApiKey;
|
||||
}
|
||||
|
||||
return mangleMap;
|
||||
}
|
||||
}
|
||||
|
||||
60
util/Seeder/IQuery.cs
Normal file
60
util/Seeder/IQuery.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace Bit.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Base interface for query operations in the seeding system. The base interface should not be used directly, rather use `IQuery<TRequest, TResult>`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Queries are synchronous, read-only operations that retrieve data from the seeding context.
|
||||
/// Unlike scenes which create data, queries fetch existing data based on request parameters.
|
||||
/// They follow a type-safe pattern using generics to ensure proper request/response handling
|
||||
/// while maintaining a common non-generic interface for dynamic invocation.
|
||||
/// </remarks>
|
||||
public interface IQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of request this query expects.
|
||||
/// </summary>
|
||||
/// <returns>The request type that this query can process.</returns>
|
||||
Type GetRequestType();
|
||||
|
||||
/// <summary>
|
||||
/// Executes the query based on the provided request object.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the query operation.</param>
|
||||
/// <returns>The query result data as an object.</returns>
|
||||
object Execute(object request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic query interface for synchronous, read-only operations with specific request and result types.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of request object this query accepts.</typeparam>
|
||||
/// <typeparam name="TResult">The type of data this query returns.</typeparam>
|
||||
/// <remarks>
|
||||
/// Use this interface when you need to retrieve existing data from the seeding context based on
|
||||
/// specific request parameters. Queries are synchronous and do not modify data - they only read
|
||||
/// and return information. The explicit interface implementations allow dynamic invocation while
|
||||
/// maintaining type safety in the implementation.
|
||||
/// </remarks>
|
||||
public interface IQuery<TRequest, TResult> : IQuery where TRequest : class where TResult : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the query based on the provided strongly-typed request and returns typed result data.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the query operation.</param>
|
||||
/// <returns>The typed query result data.</returns>
|
||||
TResult Execute(TRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request type for this query.
|
||||
/// </summary>
|
||||
/// <returns>The type of TRequest.</returns>
|
||||
Type IQuery.GetRequestType() => typeof(TRequest);
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the non-generic Execute to the strongly-typed version.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object to cast and process.</param>
|
||||
/// <returns>The typed result cast to object.</returns>
|
||||
object IQuery.Execute(object request) => Execute((TRequest)request);
|
||||
}
|
||||
96
util/Seeder/IScene.cs
Normal file
96
util/Seeder/IScene.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
namespace Bit.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Base interface for seeding operations. The base interface should not be used directly, rather use `IScene<Request>`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scenes are components in the seeding system that create and configure test data. They follow
|
||||
/// a type-safe pattern using generics to ensure proper request/response handling while maintaining
|
||||
/// a common non-generic interface for dynamic invocation.
|
||||
/// </remarks>
|
||||
public interface IScene
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of request this scene expects.
|
||||
/// </summary>
|
||||
/// <returns>The request type that this scene can process.</returns>
|
||||
Type GetRequestType();
|
||||
|
||||
/// <summary>
|
||||
/// Seeds data based on the provided request object.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the seeding operation.</param>
|
||||
/// <returns>A scene result containing any returned data, mangle map, and entity tracking information.</returns>
|
||||
Task<SceneResult<object?>> SeedAsync(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">The type of request object this scene accepts.</typeparam>
|
||||
/// <remarks>
|
||||
/// Use this interface when your scene needs to process a specific request type but doesn't need to
|
||||
/// return any data beyond the standard mangle map for ID transformations and entity tracking.
|
||||
/// The explicit interface implementations allow this scene to be invoked dynamically through the
|
||||
/// base IScene interface while maintaining type safety in the implementation.
|
||||
/// </remarks>
|
||||
public interface IScene<TRequest> : IScene where TRequest : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds data based on the provided strongly-typed request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the seeding operation.</param>
|
||||
/// <returns>A scene result containing the mangle map and entity tracking information.</returns>
|
||||
Task<SceneResult> SeedAsync(TRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request type for this scene.
|
||||
/// </summary>
|
||||
/// <returns>The type of TRequest.</returns>
|
||||
Type IScene.GetRequestType() => typeof(TRequest);
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the non-generic SeedAsync to the strongly-typed version.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object to cast and process.</param>
|
||||
/// <returns>A scene result wrapped as an object result.</returns>
|
||||
async Task<SceneResult<object?>> IScene.SeedAsync(object request)
|
||||
{
|
||||
var result = await SeedAsync((TRequest)request);
|
||||
return new SceneResult(mangleMap: result.MangleMap);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic scene interface for seeding operations with a specific request type that returns typed data.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of request object this scene accepts. Must be a reference type.</typeparam>
|
||||
/// <typeparam name="TResult">The type of data this scene returns. Must be a reference type.</typeparam>
|
||||
/// <remarks>
|
||||
/// Use this interface when your scene needs to return specific data that can be used by subsequent
|
||||
/// scenes or test logic. The result is wrapped in a SceneResult that also includes the mangle map
|
||||
/// and entity tracking information. The explicit interface implementations allow dynamic invocation
|
||||
/// while preserving type safety in the implementation.
|
||||
/// </remarks>
|
||||
public interface IScene<TRequest, TResult> : IScene where TRequest : class where TResult : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds data based on the provided strongly-typed request and returns typed result data.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the seeding operation.</param>
|
||||
/// <returns>A scene result containing the typed result data, mangle map, and entity tracking information.</returns>
|
||||
Task<SceneResult<TResult>> SeedAsync(TRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request type for this scene.
|
||||
/// </summary>
|
||||
/// <returns>The type of TRequest.</returns>
|
||||
Type IScene.GetRequestType() => typeof(TRequest);
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the non-generic SeedAsync to the strongly-typed version.
|
||||
/// </summary>
|
||||
/// <param name="request">The request object to cast and process.</param>
|
||||
/// <returns>A scene result with the typed result cast to object.</returns>
|
||||
async Task<SceneResult<object?>> IScene.SeedAsync(object request) => (SceneResult<object?>)await SeedAsync((TRequest)request);
|
||||
}
|
||||
19
util/Seeder/MangleId.cs
Normal file
19
util/Seeder/MangleId.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Bit.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for generating unique identifier suffixes to prevent collisions in test data.
|
||||
/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.)
|
||||
/// to ensure uniqueness across multiple test runs and parallel test executions.
|
||||
/// </summary>
|
||||
public class MangleId
|
||||
{
|
||||
public readonly string Value;
|
||||
|
||||
public MangleId()
|
||||
{
|
||||
// Generate a short random string (6 char) to use as the mangle ID
|
||||
Value = Random.Shared.NextInt64().ToString("x").Substring(0, 8);
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
35
util/Seeder/Queries/EmergencyAccessInviteQuery.cs
Normal file
35
util/Seeder/Queries/EmergencyAccessInviteQuery.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
namespace Bit.Seeder.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all emergency access invite urls for the provided email.
|
||||
/// </summary>
|
||||
public class EmergencyAccessInviteQuery(
|
||||
DatabaseContext db,
|
||||
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
|
||||
: IQuery<EmergencyAccessInviteQuery.Request, IEnumerable<string>>
|
||||
{
|
||||
public class Request
|
||||
{
|
||||
[Required]
|
||||
public required string Email { get; set; }
|
||||
}
|
||||
|
||||
public IEnumerable<string> Execute(Request request)
|
||||
{
|
||||
var invites = db.EmergencyAccesses
|
||||
.Where(ea => ea.Email == request.Email).ToList().Select(ea =>
|
||||
{
|
||||
var token = dataProtectorTokenizer.Protect(
|
||||
new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1)
|
||||
);
|
||||
return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}";
|
||||
});
|
||||
|
||||
return invites;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Seeder.Factories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
@@ -12,14 +12,14 @@ public class OrganizationWithUsersRecipe(DatabaseContext db)
|
||||
{
|
||||
var seats = Math.Max(users + 1, 1000);
|
||||
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
|
||||
var ownerUser = UserSeeder.CreateUser($"owner@{domain}");
|
||||
var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}");
|
||||
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
|
||||
|
||||
var additionalUsers = new List<User>();
|
||||
var additionalOrgUsers = new List<OrganizationUser>();
|
||||
for (var i = 0; i < users; i++)
|
||||
{
|
||||
var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}");
|
||||
var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}");
|
||||
additionalUsers.Add(additionalUser);
|
||||
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
|
||||
}
|
||||
|
||||
28
util/Seeder/SceneResult.cs
Normal file
28
util/Seeder/SceneResult.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Bit.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for exposing a <see cref="IScene" /> interface with a SeedAsync method.
|
||||
/// </summary>
|
||||
public class SceneResult(Dictionary<string, string?> mangleMap)
|
||||
: SceneResult<object?>(result: null, mangleMap: mangleMap);
|
||||
|
||||
/// <summary>
|
||||
/// Generic result from executing a Scene.
|
||||
/// Contains custom scene-specific data and a mangle map that maps magic strings from the
|
||||
/// request to their mangled (collision-free) values inserted into the database.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type of custom result data returned by the scene.</typeparam>
|
||||
public class SceneResult<TResult>(TResult result, Dictionary<string, string?> mangleMap)
|
||||
{
|
||||
public TResult Result { get; init; } = result;
|
||||
public Dictionary<string, string?> MangleMap { get; init; } = mangleMap;
|
||||
|
||||
public static explicit operator SceneResult<object?>(SceneResult<TResult> v)
|
||||
{
|
||||
var result = v.Result;
|
||||
|
||||
return result is null
|
||||
? new SceneResult<object?>(result: null, mangleMap: v.MangleMap)
|
||||
: new SceneResult<object?>(result: result, mangleMap: v.MangleMap);
|
||||
}
|
||||
}
|
||||
38
util/Seeder/Scenes/SingleUserScene.cs
Normal file
38
util/Seeder/Scenes/SingleUserScene.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Seeder.Factories;
|
||||
|
||||
namespace Bit.Seeder.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single user using the provided account details.
|
||||
/// </summary>
|
||||
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request>
|
||||
{
|
||||
public class Request
|
||||
{
|
||||
[Required]
|
||||
public required string Email { get; set; }
|
||||
public bool EmailVerified { get; set; } = false;
|
||||
public bool Premium { get; set; } = false;
|
||||
}
|
||||
|
||||
public async Task<SceneResult> SeedAsync(Request request)
|
||||
{
|
||||
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
|
||||
|
||||
await userRepository.CreateAsync(user);
|
||||
|
||||
return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData
|
||||
{
|
||||
Email = request.Email,
|
||||
Id = user.Id,
|
||||
Key = user.Key,
|
||||
PublicKey = user.PublicKey,
|
||||
PrivateKey = user.PrivateKey,
|
||||
ApiKey = user.ApiKey,
|
||||
Kdf = user.Kdf,
|
||||
KdfIterations = user.KdfIterations,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,6 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Settings\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj" />
|
||||
|
||||
36
util/SeederApi/Commands/DestroyBatchScenesCommand.cs
Normal file
36
util/SeederApi/Commands/DestroyBatchScenesCommand.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Bit.SeederApi.Commands.Interfaces;
|
||||
|
||||
namespace Bit.SeederApi.Commands;
|
||||
|
||||
public class DestroyBatchScenesCommand(
|
||||
ILogger<DestroyBatchScenesCommand> logger,
|
||||
IDestroySceneCommand destroySceneCommand) : IDestroyBatchScenesCommand
|
||||
{
|
||||
public async Task DestroyAsync(IEnumerable<string> playIds)
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
var deleteTasks = playIds.Select(async playId =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await destroySceneCommand.DestroyAsync(playId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (exceptions)
|
||||
{
|
||||
exceptions.Add(ex);
|
||||
}
|
||||
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(deleteTasks);
|
||||
|
||||
if (exceptions.Count > 0)
|
||||
{
|
||||
throw new AggregateException("One or more errors occurred while deleting seeded data", exceptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
util/SeederApi/Commands/DestroySceneCommand.cs
Normal file
57
util/SeederApi/Commands/DestroySceneCommand.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.SeederApi.Commands.Interfaces;
|
||||
using Bit.SeederApi.Services;
|
||||
|
||||
namespace Bit.SeederApi.Commands;
|
||||
|
||||
public class DestroySceneCommand(
|
||||
DatabaseContext databaseContext,
|
||||
ILogger<DestroySceneCommand> logger,
|
||||
IUserRepository userRepository,
|
||||
IPlayItemRepository playItemRepository,
|
||||
IOrganizationRepository organizationRepository) : IDestroySceneCommand
|
||||
{
|
||||
public async Task<object?> DestroyAsync(string playId)
|
||||
{
|
||||
// Note, delete cascade will remove PlayItem entries
|
||||
|
||||
var playItem = await playItemRepository.GetByPlayIdAsync(playId);
|
||||
var userIds = playItem.Select(pd => pd.UserId).Distinct().ToList();
|
||||
var organizationIds = playItem.Select(pd => pd.OrganizationId).Distinct().ToList();
|
||||
|
||||
// Delete Users before Organizations to respect foreign key constraints
|
||||
if (userIds.Count > 0)
|
||||
{
|
||||
var users = databaseContext.Users.Where(u => userIds.Contains(u.Id));
|
||||
await userRepository.DeleteManyAsync(users);
|
||||
}
|
||||
|
||||
if (organizationIds.Count > 0)
|
||||
{
|
||||
var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id));
|
||||
var aggregateException = new AggregateException();
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
try
|
||||
{
|
||||
await organizationRepository.DeleteAsync(org);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
aggregateException = new AggregateException(aggregateException, ex);
|
||||
}
|
||||
}
|
||||
if (aggregateException.InnerExceptions.Count > 0)
|
||||
{
|
||||
throw new SceneExecutionException(
|
||||
$"One or more errors occurred while deleting organizations for seed ID {playId}",
|
||||
aggregateException);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}", playId);
|
||||
|
||||
return new { PlayId = playId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Bit.SeederApi.Commands.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command for destroying multiple scenes in parallel.
|
||||
/// </summary>
|
||||
public interface IDestroyBatchScenesCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Destroys multiple scenes by their play IDs in parallel.
|
||||
/// </summary>
|
||||
/// <param name="playIds">The list of play IDs to destroy</param>
|
||||
/// <exception cref="AggregateException">Thrown when one or more scenes fail to destroy</exception>
|
||||
Task DestroyAsync(IEnumerable<string> playIds);
|
||||
}
|
||||
15
util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs
Normal file
15
util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Bit.SeederApi.Commands.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command for destroying data created by a single scene.
|
||||
/// </summary>
|
||||
public interface IDestroySceneCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Destroys data created by a scene using the seeded data ID.
|
||||
/// </summary>
|
||||
/// <param name="playId">The ID of the seeded data to destroy</param>
|
||||
/// <returns>The result of the destroy operation</returns>
|
||||
/// <exception cref="Services.SceneExecutionException">Thrown when there's an error destroying the seeded data</exception>
|
||||
Task<object?> DestroyAsync(string playId);
|
||||
}
|
||||
20
util/SeederApi/Controllers/InfoController.cs
Normal file
20
util/SeederApi/Controllers/InfoController.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.SeederApi.Controllers;
|
||||
|
||||
public class InfoController : Controller
|
||||
{
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
return Json(AssemblyHelpers.GetVersion());
|
||||
}
|
||||
}
|
||||
32
util/SeederApi/Controllers/QueryController.cs
Normal file
32
util/SeederApi/Controllers/QueryController.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Bit.SeederApi.Execution;
|
||||
using Bit.SeederApi.Models.Request;
|
||||
using Bit.SeederApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.SeederApi.Controllers;
|
||||
|
||||
[Route("query")]
|
||||
public class QueryController(ILogger<QueryController> logger, IQueryExecutor queryExecutor) : Controller
|
||||
{
|
||||
[HttpPost]
|
||||
public IActionResult Query([FromBody] QueryRequestModel request)
|
||||
{
|
||||
logger.LogInformation("Executing query: {Query}", request.Template);
|
||||
|
||||
try
|
||||
{
|
||||
var result = queryExecutor.Execute(request.Template, request.Arguments);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
catch (QueryNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { Error = ex.Message });
|
||||
}
|
||||
catch (QueryExecutionException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error executing query: {Query}", request.Template);
|
||||
return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
100
util/SeederApi/Controllers/SeedController.cs
Normal file
100
util/SeederApi/Controllers/SeedController.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Bit.SeederApi.Commands.Interfaces;
|
||||
using Bit.SeederApi.Execution;
|
||||
using Bit.SeederApi.Models.Request;
|
||||
using Bit.SeederApi.Queries.Interfaces;
|
||||
using Bit.SeederApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.SeederApi.Controllers;
|
||||
|
||||
[Route("seed")]
|
||||
public class SeedController(
|
||||
ILogger<SeedController> logger,
|
||||
ISceneExecutor sceneExecutor,
|
||||
IDestroySceneCommand destroySceneCommand,
|
||||
IDestroyBatchScenesCommand destroyBatchScenesCommand,
|
||||
IGetAllPlayIdsQuery getAllPlayIdsQuery) : Controller
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SeedAsync([FromBody] SeedRequestModel request)
|
||||
{
|
||||
logger.LogInformation("Received seed request with template: {Template}", request.Template);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await sceneExecutor.ExecuteAsync(request.Template, request.Arguments);
|
||||
|
||||
return Json(response);
|
||||
}
|
||||
catch (SceneNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { Error = ex.Message });
|
||||
}
|
||||
catch (SceneExecutionException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error executing scene: {Template}", request.Template);
|
||||
return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("batch")]
|
||||
public async Task<IActionResult> DeleteBatchAsync([FromBody] List<string> playIds)
|
||||
{
|
||||
logger.LogInformation("Deleting batch of seeded data with IDs: {PlayIds}", string.Join(", ", playIds));
|
||||
|
||||
try
|
||||
{
|
||||
await destroyBatchScenesCommand.DestroyAsync(playIds);
|
||||
return Ok(new { Message = "Batch delete completed successfully" });
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
Error = ex.Message,
|
||||
Details = ex.InnerExceptions.Select(e => e.Message).ToList()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{playId}")]
|
||||
public async Task<IActionResult> DeleteAsync([FromRoute] string playId)
|
||||
{
|
||||
logger.LogInformation("Deleting seeded data with ID: {PlayId}", playId);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await destroySceneCommand.DestroyAsync(playId);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
catch (SceneExecutionException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
|
||||
return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[HttpDelete]
|
||||
public async Task<IActionResult> DeleteAllAsync()
|
||||
{
|
||||
logger.LogInformation("Deleting all seeded data");
|
||||
|
||||
var playIds = getAllPlayIdsQuery.GetAllPlayIds();
|
||||
|
||||
try
|
||||
{
|
||||
await destroyBatchScenesCommand.DestroyAsync(playIds);
|
||||
return NoContent();
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
Error = ex.Message,
|
||||
Details = ex.InnerExceptions.Select(e => e.Message).ToList()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
22
util/SeederApi/Execution/IQueryExecutor.cs
Normal file
22
util/SeederApi/Execution/IQueryExecutor.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.SeederApi.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for dynamically resolving and executing queries by name.
|
||||
/// This is an infrastructure component that orchestrates query execution,
|
||||
/// not a domain-level query.
|
||||
/// </summary>
|
||||
public interface IQueryExecutor
|
||||
{
|
||||
/// <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="Services.QueryNotFoundException">Thrown when the query is not found</exception>
|
||||
/// <exception cref="Services.QueryExecutionException">Thrown when there's an error executing the query</exception>
|
||||
object Execute(string queryName, JsonElement? arguments);
|
||||
}
|
||||
22
util/SeederApi/Execution/ISceneExecutor.cs
Normal file
22
util/SeederApi/Execution/ISceneExecutor.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json;
|
||||
using Bit.SeederApi.Models.Response;
|
||||
|
||||
namespace Bit.SeederApi.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for dynamically resolving and executing scenes by template name.
|
||||
/// This is an infrastructure component that orchestrates scene execution,
|
||||
/// not a domain-level command.
|
||||
/// </summary>
|
||||
public interface ISceneExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a scene with the given template name and arguments.
|
||||
/// </summary>
|
||||
/// <param name="templateName">The name of the scene template (e.g., "SingleUserScene")</param>
|
||||
/// <param name="arguments">Optional JSON arguments to pass to the scene's Seed method</param>
|
||||
/// <returns>A scene response model containing the result and mangle map</returns>
|
||||
/// <exception cref="Services.SceneNotFoundException">Thrown when the scene template is not found</exception>
|
||||
/// <exception cref="Services.SceneExecutionException">Thrown when there's an error executing the scene</exception>
|
||||
Task<SceneResponseModel> ExecuteAsync(string templateName, JsonElement? arguments);
|
||||
}
|
||||
19
util/SeederApi/Execution/JsonConfiguration.cs
Normal file
19
util/SeederApi/Execution/JsonConfiguration.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.SeederApi.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared JSON serialization configuration for executors.
|
||||
/// </summary>
|
||||
internal static class JsonConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard JSON serializer options used for deserializing scene and query request models.
|
||||
/// Uses case-insensitive property matching and camelCase naming policy.
|
||||
/// </summary>
|
||||
internal static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
77
util/SeederApi/Execution/QueryExecutor.cs
Normal file
77
util/SeederApi/Execution/QueryExecutor.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Seeder;
|
||||
using Bit.SeederApi.Services;
|
||||
|
||||
namespace Bit.SeederApi.Execution;
|
||||
|
||||
public class QueryExecutor(
|
||||
ILogger<QueryExecutor> logger,
|
||||
IServiceProvider serviceProvider) : IQueryExecutor
|
||||
{
|
||||
|
||||
public object Execute(string queryName, JsonElement? arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = serviceProvider.GetKeyedService<IQuery>(queryName)
|
||||
?? throw new QueryNotFoundException(queryName);
|
||||
|
||||
var requestType = query.GetRequestType();
|
||||
var requestModel = DeserializeRequestModel(queryName, requestType, arguments);
|
||||
var result = query.Execute(requestModel);
|
||||
|
||||
logger.LogInformation("Successfully executed query: {QueryName}", queryName);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not QueryNotFoundException and not QueryExecutionException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected error executing query: {QueryName}", queryName);
|
||||
throw new QueryExecutionException(
|
||||
$"An unexpected error occurred while executing query '{queryName}'",
|
||||
ex.InnerException ?? ex);
|
||||
}
|
||||
}
|
||||
|
||||
private object DeserializeRequestModel(string queryName, Type requestType, JsonElement? arguments)
|
||||
{
|
||||
if (arguments == null)
|
||||
{
|
||||
return CreateDefaultRequestModel(queryName, requestType);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options);
|
||||
if (requestModel == null)
|
||||
{
|
||||
throw new QueryExecutionException(
|
||||
$"Failed to deserialize request model for query '{queryName}'");
|
||||
}
|
||||
return requestModel;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new QueryExecutionException(
|
||||
$"Failed to deserialize request model for query '{queryName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private object CreateDefaultRequestModel(string queryName, Type requestType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestModel = Activator.CreateInstance(requestType);
|
||||
if (requestModel == null)
|
||||
{
|
||||
throw new QueryExecutionException(
|
||||
$"Arguments are required for query '{queryName}'");
|
||||
}
|
||||
return requestModel;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new QueryExecutionException(
|
||||
$"Arguments are required for query '{queryName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
78
util/SeederApi/Execution/SceneExecutor.cs
Normal file
78
util/SeederApi/Execution/SceneExecutor.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Seeder;
|
||||
using Bit.SeederApi.Models.Response;
|
||||
using Bit.SeederApi.Services;
|
||||
|
||||
namespace Bit.SeederApi.Execution;
|
||||
|
||||
public class SceneExecutor(
|
||||
ILogger<SceneExecutor> logger,
|
||||
IServiceProvider serviceProvider) : ISceneExecutor
|
||||
{
|
||||
|
||||
public async Task<SceneResponseModel> ExecuteAsync(string templateName, JsonElement? arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scene = serviceProvider.GetKeyedService<IScene>(templateName)
|
||||
?? throw new SceneNotFoundException(templateName);
|
||||
|
||||
var requestType = scene.GetRequestType();
|
||||
var requestModel = DeserializeRequestModel(templateName, requestType, arguments);
|
||||
var result = await scene.SeedAsync(requestModel);
|
||||
|
||||
logger.LogInformation("Successfully executed scene: {TemplateName}", templateName);
|
||||
return SceneResponseModel.FromSceneResult(result);
|
||||
}
|
||||
catch (Exception ex) when (ex is not SceneNotFoundException and not SceneExecutionException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected error executing scene: {TemplateName}", templateName);
|
||||
throw new SceneExecutionException(
|
||||
$"An unexpected error occurred while executing scene '{templateName}'",
|
||||
ex.InnerException ?? ex);
|
||||
}
|
||||
}
|
||||
|
||||
private object DeserializeRequestModel(string templateName, Type requestType, JsonElement? arguments)
|
||||
{
|
||||
if (arguments == null)
|
||||
{
|
||||
return CreateDefaultRequestModel(templateName, requestType);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options);
|
||||
if (requestModel == null)
|
||||
{
|
||||
throw new SceneExecutionException(
|
||||
$"Failed to deserialize request model for scene '{templateName}'");
|
||||
}
|
||||
return requestModel;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new SceneExecutionException(
|
||||
$"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private object CreateDefaultRequestModel(string templateName, Type requestType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestModel = Activator.CreateInstance(requestType);
|
||||
if (requestModel == null)
|
||||
{
|
||||
throw new SceneExecutionException(
|
||||
$"Arguments are required for scene '{templateName}'");
|
||||
}
|
||||
return requestModel;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new SceneExecutionException(
|
||||
$"Arguments are required for scene '{templateName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
76
util/SeederApi/Extensions/ServiceCollectionExtensions.cs
Normal file
76
util/SeederApi/Extensions/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Reflection;
|
||||
using Bit.Seeder;
|
||||
using Bit.SeederApi.Commands;
|
||||
using Bit.SeederApi.Commands.Interfaces;
|
||||
using Bit.SeederApi.Execution;
|
||||
using Bit.SeederApi.Queries;
|
||||
using Bit.SeederApi.Queries.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Bit.SeederApi.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers SeederApi executors, commands, and queries.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSeederApiServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ISceneExecutor, SceneExecutor>();
|
||||
services.AddScoped<IQueryExecutor, QueryExecutor>();
|
||||
|
||||
services.AddScoped<IDestroySceneCommand, DestroySceneCommand>();
|
||||
services.AddScoped<IDestroyBatchScenesCommand, DestroyBatchScenesCommand>();
|
||||
|
||||
services.AddScoped<IGetAllPlayIdsQuery, GetAllPlayIdsQuery>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dynamically registers all scene types that implement IScene<TRequest> from the Seeder assembly.
|
||||
/// Scenes are registered as keyed scoped services using their class name as the key.
|
||||
/// </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 &&
|
||||
isIScene(i.GetGenericTypeDefinition())));
|
||||
|
||||
foreach (var sceneType in sceneTypes)
|
||||
{
|
||||
services.TryAddScoped(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 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() == iQueryType));
|
||||
|
||||
foreach (var queryType in queryTypes)
|
||||
{
|
||||
services.TryAddScoped(queryType);
|
||||
services.TryAddKeyedScoped(typeof(IQuery), queryType.Name, (sp, _) => sp.GetRequiredService(queryType));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
11
util/SeederApi/Models/Request/QueryRequestModel.cs
Normal file
11
util/SeederApi/Models/Request/QueryRequestModel.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.SeederApi.Models.Request;
|
||||
|
||||
public class QueryRequestModel
|
||||
{
|
||||
[Required]
|
||||
public required string Template { get; set; }
|
||||
public JsonElement? Arguments { get; set; }
|
||||
}
|
||||
11
util/SeederApi/Models/Request/SeedRequestModel.cs
Normal file
11
util/SeederApi/Models/Request/SeedRequestModel.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.SeederApi.Models.Request;
|
||||
|
||||
public class SeedRequestModel
|
||||
{
|
||||
[Required]
|
||||
public required string Template { get; set; }
|
||||
public JsonElement? Arguments { get; set; }
|
||||
}
|
||||
18
util/SeederApi/Models/Response/SeedResponseModel.cs
Normal file
18
util/SeederApi/Models/Response/SeedResponseModel.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Bit.Seeder;
|
||||
|
||||
namespace Bit.SeederApi.Models.Response;
|
||||
|
||||
public class SceneResponseModel
|
||||
{
|
||||
public required Dictionary<string, string?>? MangleMap { get; init; }
|
||||
public required object? Result { get; init; }
|
||||
|
||||
public static SceneResponseModel FromSceneResult<T>(SceneResult<T> sceneResult)
|
||||
{
|
||||
return new SceneResponseModel
|
||||
{
|
||||
Result = sceneResult.Result,
|
||||
MangleMap = sceneResult.MangleMap,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
util/SeederApi/Program.cs
Normal file
20
util/SeederApi/Program.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.SeederApi;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
})
|
||||
.AddSerilogFileLogging()
|
||||
.Build()
|
||||
.Run();
|
||||
}
|
||||
}
|
||||
37
util/SeederApi/Properties/launchSettings.json
Normal file
37
util/SeederApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:5047",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"SeederApi": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5047",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"SeederApi-SelfHost": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5048",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"developSelfHosted": "true"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
util/SeederApi/Queries/GetAllPlayIdsQuery.cs
Normal file
15
util/SeederApi/Queries/GetAllPlayIdsQuery.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.SeederApi.Queries.Interfaces;
|
||||
|
||||
namespace Bit.SeederApi.Queries;
|
||||
|
||||
public class GetAllPlayIdsQuery(DatabaseContext databaseContext) : IGetAllPlayIdsQuery
|
||||
{
|
||||
public List<string> GetAllPlayIds()
|
||||
{
|
||||
return databaseContext.PlayItem
|
||||
.Select(pd => pd.PlayId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
13
util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs
Normal file
13
util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Bit.SeederApi.Queries.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Query for retrieving all play IDs for currently tracked seeded data.
|
||||
/// </summary>
|
||||
public interface IGetAllPlayIdsQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves all play IDs for currently tracked seeded data.
|
||||
/// </summary>
|
||||
/// <returns>A list of play IDs representing active seeded data that can be destroyed.</returns>
|
||||
List<string> GetAllPlayIds();
|
||||
}
|
||||
185
util/SeederApi/README.md
Normal file
185
util/SeederApi/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# SeederApi
|
||||
|
||||
A web API for dynamically seeding and querying test data in the Bitwarden database during development and testing.
|
||||
|
||||
## Overview
|
||||
|
||||
The SeederApi provides HTTP endpoints to execute [Seeder](../Seeder/README.md) scenes and queries, enabling automated test data
|
||||
generation and retrieval through a RESTful interface. This is particularly useful for integration testing, local
|
||||
development workflows, and automated test environments.
|
||||
|
||||
## Architecture
|
||||
|
||||
The SeederApi consists of three main components:
|
||||
|
||||
1. **Controllers** - HTTP endpoints for seeding, querying, and managing test data
|
||||
2. **Services** - Business logic for scene and query execution
|
||||
3. **Models** - Request/response models for API communication
|
||||
|
||||
### Key Components
|
||||
|
||||
- **SeedController** (`/seed`) - Creates and destroys seeded test data
|
||||
- **QueryController** (`/query`) - Executes read-only queries against existing data
|
||||
- **InfoController** (`/alive`, `/version`) - Health check and version information
|
||||
- **SceneService** - Manages scene execution and cleanup with play ID tracking
|
||||
- **QueryService** - Executes read-only query operations
|
||||
|
||||
## How To Use
|
||||
|
||||
### Starting the API
|
||||
|
||||
```bash
|
||||
cd util/SeederApi
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The API will start on the configured port (typically `http://localhost:5000`).
|
||||
|
||||
### Seeding Data
|
||||
|
||||
Send a POST request to `/seed` with a scene template name and optional arguments. Include the `X-Play-Id` header to
|
||||
track the seeded data for later cleanup:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/seed \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Play-Id: test-run-123" \
|
||||
-d '{
|
||||
"template": "SingleUserScene",
|
||||
"arguments": {
|
||||
"email": "test@example.com"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mangleMap": {
|
||||
"test@example.com": "1854b016+test@example.com",
|
||||
"42bcf05d-7ad0-4e27-8b53-b3b700acc664": "42bcf05d-7ad0-4e27-8b53-b3b700acc664"
|
||||
},
|
||||
"result": null
|
||||
}
|
||||
```
|
||||
|
||||
The `result` contains the data returned by the scene, and `mangleMap` contains ID mappings if ID mangling is enabled.
|
||||
Use the `X-Play-Id` header value to later destroy the seeded data.
|
||||
|
||||
### Querying Data
|
||||
|
||||
Send a POST request to `/query` to execute read-only queries:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/query \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"template": "EmergencyAccessInviteQuery",
|
||||
"arguments": {
|
||||
"email": "test@example.com"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
["/accept-emergency?..."]
|
||||
```
|
||||
|
||||
### Destroying Seeded Data
|
||||
|
||||
#### Delete by Play ID
|
||||
|
||||
Use the same play ID value you provided in the `X-Play-Id` header:
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:5000/seed/test-run-123
|
||||
```
|
||||
|
||||
#### Delete Multiple by Play IDs
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:5000/seed/batch \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '["test-run-123", "test-run-456"]'
|
||||
```
|
||||
|
||||
#### Delete All Seeded Data
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:5000/seed
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check if API is alive
|
||||
curl http://localhost:5000/alive
|
||||
|
||||
# Get API version
|
||||
curl http://localhost:5000/version
|
||||
```
|
||||
|
||||
## Creating Scenes and Queries
|
||||
|
||||
Scenes and queries are defined in the [Seeder](../Seeder/README.md) project. The SeederApi automatically discovers and registers all
|
||||
classes implementing the scene and query interfaces.
|
||||
|
||||
## Configuration
|
||||
|
||||
The SeederApi uses the standard Bitwarden configuration system:
|
||||
|
||||
- `appsettings.json` - Base configuration
|
||||
- `appsettings.Development.json` - Development overrides
|
||||
- `dev/secrets.json` - Local secrets (database connection strings, etc.)
|
||||
- User Secrets ID: `bitwarden-seeder-api`
|
||||
|
||||
### Required Settings
|
||||
|
||||
The SeederApi requires the following configuration:
|
||||
|
||||
- **Database Connection** - Connection string to the Bitwarden database
|
||||
- **Global Settings** - Standard Bitwarden `GlobalSettings` configuration
|
||||
|
||||
## Play ID Tracking
|
||||
|
||||
Certain entities such as Users and Organizations are tracked when created by a request including a PlayId. This enables
|
||||
entities to be deleted after using the PlayId.
|
||||
|
||||
### The X-Play-Id Header
|
||||
|
||||
**Important:** All seed requests should include the `X-Play-Id` header:
|
||||
|
||||
```bash
|
||||
-H "X-Play-Id: your-unique-identifier"
|
||||
```
|
||||
|
||||
The play ID can be any string that uniquely identifies your test run or session. Common patterns:
|
||||
|
||||
### How Play ID Tracking Works
|
||||
|
||||
When `TestPlayIdTrackingEnabled` is enabled in GlobalSettings, the `PlayIdMiddleware`
|
||||
(see `src/SharedWeb/Utilities/PlayIdMiddleware.cs:7-23`) automatically:
|
||||
|
||||
1. **Extracts** the `X-Play-Id` header from incoming requests
|
||||
2. **Sets** the play ID in the `PlayIdService` for the request scope
|
||||
3. **Tracks** all entities (users, organizations, etc.) created during the request
|
||||
4. **Associates** them with the play ID in the `PlayItem` table
|
||||
5. **Enables** complete cleanup via the delete endpoints
|
||||
|
||||
This tracking works for **any API request** that includes the `X-Play-Id` header, not just SeederApi endpoints. This means
|
||||
you can track entities created through:
|
||||
|
||||
- **Scene executions** - Data seeded via `/seed` endpoint
|
||||
- **Regular API operations** - Users signing up, creating organizations, inviting members, etc.
|
||||
- **Integration tests** - Any HTTP requests to the Bitwarden API during test execution
|
||||
|
||||
Without the `X-Play-Id` header, entities will not be tracked and cannot be cleaned up using the delete endpoints.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
> [!WARNING]
|
||||
> The SeederApi is intended for **development and testing environments only**. Never deploy this API to production
|
||||
> environments.
|
||||
16
util/SeederApi/SeederApi.csproj
Normal file
16
util/SeederApi/SeederApi.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-seeder-api</UserSecretsId>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<StaticWebAssetsEnabled>false</StaticWebAssetsEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\SharedWeb\SharedWeb.csproj" />
|
||||
<ProjectReference Include="..\Seeder\Seeder.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
util/SeederApi/Services/QueryExceptions.cs
Normal file
10
util/SeederApi/Services/QueryExceptions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Bit.SeederApi.Services;
|
||||
|
||||
public class QueryNotFoundException(string query) : Exception($"Query '{query}' not found");
|
||||
|
||||
public class QueryExecutionException : Exception
|
||||
{
|
||||
public QueryExecutionException(string message) : base(message) { }
|
||||
public QueryExecutionException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
10
util/SeederApi/Services/SceneExceptions.cs
Normal file
10
util/SeederApi/Services/SceneExceptions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Bit.SeederApi.Services;
|
||||
|
||||
public class SceneNotFoundException(string scene) : Exception($"Scene '{scene}' not found");
|
||||
|
||||
public class SceneExecutionException : Exception
|
||||
{
|
||||
public SceneExecutionException(string message) : base(message) { }
|
||||
public SceneExecutionException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
80
util/SeederApi/Startup.cs
Normal file
80
util/SeederApi/Startup.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Seeder;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.SeederApi.Extensions;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Bit.SeederApi;
|
||||
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IWebHostEnvironment env, IConfiguration configuration)
|
||||
{
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
|
||||
Configuration = configuration;
|
||||
Environment = env;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; private set; }
|
||||
public IWebHostEnvironment Environment { get; set; }
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddOptions();
|
||||
|
||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
services.AddTokenizers();
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
|
||||
services.AddScoped<IPasswordHasher<Core.Entities.User>, PasswordHasher<Core.Entities.User>>();
|
||||
|
||||
services.AddSingleton<RustSDK.RustSdkService>();
|
||||
services.AddScoped<UserSeeder>();
|
||||
|
||||
services.AddSeederApiServices();
|
||||
|
||||
services.AddScoped<MangleId>(_ => new MangleId());
|
||||
services.AddScenes();
|
||||
services.AddQueries();
|
||||
|
||||
services.AddControllers();
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
if (env.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"SeederApi cannot be run in production environments. This service is intended for test data generation only.");
|
||||
}
|
||||
|
||||
if (globalSettings.TestPlayIdTrackingEnabled)
|
||||
{
|
||||
app.UseMiddleware<PlayIdMiddleware>();
|
||||
}
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Home/Error");
|
||||
}
|
||||
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
8
util/SeederApi/appsettings.Development.json
Normal file
8
util/SeederApi/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
util/SeederApi/appsettings.json
Normal file
11
util/SeederApi/appsettings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"globalSettings": {
|
||||
"projectName": "SeederApi"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
3491
util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs
generated
Normal file
3491
util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.SqliteMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class CreatePlayItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlayItem",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlayId = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
OrganizationId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
CreationDate = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlayItem", x => x.Id);
|
||||
table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayItem_Organization_OrganizationId",
|
||||
column: x => x.OrganizationId,
|
||||
principalTable: "Organization",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayItem_User_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "User",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_OrganizationId",
|
||||
table: "PlayItem",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_PlayId",
|
||||
table: "PlayItem",
|
||||
column: "PlayId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayItem_UserId",
|
||||
table: "PlayItem",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlayItem");
|
||||
}
|
||||
}
|
||||
@@ -277,71 +277,6 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.ToTable("Organization", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId", "Type")
|
||||
.IsUnique()
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("OrganizationIntegration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("EventType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Filters")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OrganizationIntegrationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Template")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationIntegrationId");
|
||||
|
||||
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -621,7 +556,7 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.Property<byte>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WaitTimeDays")
|
||||
b.Property<short>("WaitTimeDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
@@ -1004,6 +939,71 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.ToTable("OrganizationApplication", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("OrganizationId", "Type")
|
||||
.IsUnique()
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("OrganizationIntegration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Configuration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("EventType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Filters")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OrganizationIntegrationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RevisionDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Template")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationIntegrationId");
|
||||
|
||||
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1616,6 +1616,42 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.ToTable("OrganizationUser", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("PlayId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("PlayItem", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2596,28 +2632,6 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.HasDiscriminator().HasValue("user_service_account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationIntegrationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OrganizationIntegration");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
@@ -2796,6 +2810,28 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationIntegrationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OrganizationIntegration");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
@@ -2992,6 +3028,23 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Organization");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
|
||||
{
|
||||
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
|
||||
|
||||
Reference in New Issue
Block a user