From 9fac0c83c06d97233be2e9d8070352d9074ee3c4 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 1 Jun 2026 20:57:11 +0200 Subject: [PATCH] Harden EF Core secret name uniqueness --- .../20260531142051_Initial.Designer.cs | 9 +- .../Secrets/20260531142051_Initial.cs | 6 +- .../SecretsElsaDbContextModelSnapshot.cs | 9 +- .../20260531142520_Initial.Designer.cs | 9 +- .../Secrets/20260531142520_Initial.cs | 5 +- .../SecretsElsaDbContextModelSnapshot.cs | 9 +- .../20260531141856_Initial.Designer.cs | 9 +- .../Secrets/20260531141856_Initial.cs | 5 +- .../SecretsElsaDbContextModelSnapshot.cs | 9 +- .../20260531141743_Initial.Designer.cs | 9 +- .../Secrets/20260531141743_Initial.cs | 5 +- .../SecretsElsaDbContextModelSnapshot.cs | 9 +- .../20260531141623_Initial.Designer.cs | 9 +- .../Secrets/20260531141623_Initial.cs | 5 +- .../SecretsElsaDbContextModelSnapshot.cs | 9 +- .../Repositories/EFCoreSecretRepository.cs | 158 +++++++++++------- .../SecretConfiguration.cs | 5 +- .../EFCoreSecretRepositoryTests.cs | 3 + 18 files changed, 186 insertions(+), 96 deletions(-) diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.Designer.cs index a13dd56f8..f8214ae61 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.Designer.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.Designer.cs @@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets .HasMaxLength(200) .HasColumnType("varchar(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("varchar(200)"); @@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.cs index 126b8e60b..e3bbd1205 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260531142051_Initial.cs @@ -34,6 +34,8 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets .Annotation("MySql:CharSet", "utf8mb4"), Name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), + NormalizedName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), DisplayName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), Description = table.Column(type: "longtext", nullable: true) @@ -60,10 +62,10 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets .Annotation("MySql:CharSet", "utf8mb4"); migrationBuilder.CreateIndex( - name: "IX_Secret_Name", + name: "IX_Secret_NormalizedName", schema: _schema.Schema, table: "Secrets", - column: "Name", + column: "NormalizedName", unique: true); migrationBuilder.CreateIndex( diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs index 073e16b6c..3f5ae005c 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs @@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets .HasMaxLength(200) .HasColumnType("varchar(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("varchar(200)"); @@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.Designer.cs index 85fcf68ee..e67c4d522 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.Designer.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.Designer.cs @@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets .HasMaxLength(200) .HasColumnType("NVARCHAR2(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("NVARCHAR2(200)"); @@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.cs index 48a57fbb3..eb158a1d3 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260531142520_Initial.cs @@ -29,6 +29,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets { Id = table.Column(type: "NVARCHAR2(450)", nullable: false), Name = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: false), DisplayName = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: false), Description = table.Column(type: "NVARCHAR2(2000)", nullable: true), TypeName = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: false), @@ -46,10 +47,10 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets }); migrationBuilder.CreateIndex( - name: "IX_Secret_Name", + name: "IX_Secret_NormalizedName", schema: _schema.Schema, table: "Secrets", - column: "Name", + column: "NormalizedName", unique: true); migrationBuilder.CreateIndex( diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs index 7f8973561..17051256f 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs @@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets .HasMaxLength(200) .HasColumnType("NVARCHAR2(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("NVARCHAR2(200)"); @@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.Designer.cs index c1117e28b..d4e171644 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.Designer.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.Designer.cs @@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("character varying(200)"); @@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.cs index bab80acb2..01a9590ce 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260531141856_Initial.cs @@ -29,6 +29,7 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets { Id = table.Column(type: "text", nullable: false), Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), Description = table.Column(type: "text", nullable: true), TypeName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), @@ -46,10 +47,10 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets }); migrationBuilder.CreateIndex( - name: "IX_Secret_Name", + name: "IX_Secret_NormalizedName", schema: _schema.Schema, table: "Secrets", - column: "Name", + column: "NormalizedName", unique: true); migrationBuilder.CreateIndex( diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs index 87ac39a20..d0febedf5 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs @@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("character varying(200)"); @@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.Designer.cs index e99d36f6a..91160f5a3 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.Designer.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.Designer.cs @@ -47,6 +47,11 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -81,9 +86,9 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.cs index c6eab0c90..7f8483683 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260531141743_Initial.cs @@ -29,6 +29,7 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets { Id = table.Column(type: "nvarchar(450)", nullable: false), Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), DisplayName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), Description = table.Column(type: "nvarchar(max)", nullable: true), TypeName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), @@ -46,10 +47,10 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets }); migrationBuilder.CreateIndex( - name: "IX_Secret_Name", + name: "IX_Secret_NormalizedName", schema: _schema.Schema, table: "Secrets", - column: "Name", + column: "NormalizedName", unique: true); migrationBuilder.CreateIndex( diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs index d664a769f..4e800c148 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs @@ -44,6 +44,11 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -78,9 +83,9 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.Designer.cs index 0dd5b4786..838ee5a42 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.Designer.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.Designer.cs @@ -43,6 +43,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets .HasMaxLength(200) .HasColumnType("TEXT"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("TEXT"); @@ -77,9 +82,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.cs index f7c2ca4c2..5a20b70f8 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260531141623_Initial.cs @@ -20,6 +20,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets { Id = table.Column(type: "TEXT", nullable: false), Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 200, nullable: false), DisplayName = table.Column(type: "TEXT", maxLength: 200, nullable: false), Description = table.Column(type: "TEXT", nullable: true), TypeName = table.Column(type: "TEXT", maxLength: 100, nullable: false), @@ -37,10 +38,10 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets }); migrationBuilder.CreateIndex( - name: "IX_Secret_Name", + name: "IX_Secret_NormalizedName", schema: "Elsa", table: "Secrets", - column: "Name", + column: "NormalizedName", unique: true); migrationBuilder.CreateIndex( diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs index 2865d8753..ee77914fd 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/SecretsElsaDbContextModelSnapshot.cs @@ -40,6 +40,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets .HasMaxLength(200) .HasColumnType("TEXT"); + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + b.Property("Scope") .HasMaxLength(200) .HasColumnType("TEXT"); @@ -74,9 +79,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_Name"); + .HasDatabaseName("IX_Secret_NormalizedName"); b.HasIndex("Scope") .HasDatabaseName("IX_Secret_Scope"); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs b/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs index 44e66f8a5..bf6fdaac8 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs @@ -7,8 +7,6 @@ namespace Elsa.Secrets.Persistence.EFCore.Repositories; public class EFCoreSecretRepository(Store store) : ISecretRepository { - private static readonly SemaphoreSlim Semaphore = new(1, 1); - public async Task GetAsync(string normalizedName, CancellationToken cancellationToken = default) { await using var dbContext = await store.CreateDbContextAsync(cancellationToken); @@ -30,83 +28,66 @@ public class EFCoreSecretRepository(Store store) : public async Task AddAsync(Secret secret, CancellationToken cancellationToken = default) { - await Semaphore.WaitAsync(cancellationToken); - try - { - await using var dbContext = await store.CreateDbContextAsync(cancellationToken); - var normalizedName = NormalizeName(secret.Name); - if (await dbContext.Secrets.AnyAsync(x => x.Name.ToLower() == normalizedName, cancellationToken)) - throw new InvalidOperationException($"A secret named '{secret.Name}' already exists."); + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var normalizedName = NormalizeName(secret.Name); + if (await ExistsByNormalizedNameAsync(dbContext, normalizedName, cancellationToken)) + throw new InvalidOperationException($"A secret named '{secret.Name}' already exists."); - await dbContext.Secrets.AddAsync(secret, cancellationToken); - SecretSerialization.StoreSerializedProperties(dbContext, secret); - await dbContext.SaveChangesAsync(cancellationToken); - } - finally - { - Semaphore.Release(); - } + await dbContext.Secrets.AddAsync(secret, cancellationToken); + SetNormalizedName(dbContext, secret); + SecretSerialization.StoreSerializedProperties(dbContext, secret); + await SaveChangesAsync(dbContext, secret.Name, cancellationToken); } public async Task TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default) { - await Semaphore.WaitAsync(cancellationToken); - try + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken); + + if (existingSecret == null) { - await using var dbContext = await store.CreateDbContextAsync(cancellationToken); - var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken); - - if (existingSecret == null) - { - await dbContext.Secrets.AddAsync(secret, cancellationToken); - SecretSerialization.StoreSerializedProperties(dbContext, secret); - await dbContext.SaveChangesAsync(cancellationToken); - return true; - } - - if (existingSecret.Status != SecretStatus.Deleted) - return false; - - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - dbContext.Secrets.Remove(existingSecret); - await dbContext.SaveChangesAsync(cancellationToken); await dbContext.Secrets.AddAsync(secret, cancellationToken); + SetNormalizedName(dbContext, secret); SecretSerialization.StoreSerializedProperties(dbContext, secret); - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return true; - } - finally - { - Semaphore.Release(); + return await TrySaveChangesAsync(dbContext, secret.Name, cancellationToken); } + + if (existingSecret.Status != SecretStatus.Deleted) + return false; + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + dbContext.Secrets.Remove(existingSecret); + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.Secrets.AddAsync(secret, cancellationToken); + SetNormalizedName(dbContext, secret); + SecretSerialization.StoreSerializedProperties(dbContext, secret); + + if (!await TrySaveChangesAsync(dbContext, secret.Name, cancellationToken)) + return false; + + await transaction.CommitAsync(cancellationToken); + return true; } public async Task SaveAsync(Secret secret, CancellationToken cancellationToken = default) { - await Semaphore.WaitAsync(cancellationToken); - try - { - await using var dbContext = await store.CreateDbContextAsync(cancellationToken); - var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken); + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken); - if (existingSecret == null) - { - await dbContext.Secrets.AddAsync(secret, cancellationToken); - SecretSerialization.StoreSerializedProperties(dbContext, secret); - } - else - { - Copy(secret, existingSecret); - SecretSerialization.StoreSerializedProperties(dbContext, existingSecret); - } - - await dbContext.SaveChangesAsync(cancellationToken); - } - finally + if (existingSecret == null) { - Semaphore.Release(); + await dbContext.Secrets.AddAsync(secret, cancellationToken); + SetNormalizedName(dbContext, secret); + SecretSerialization.StoreSerializedProperties(dbContext, secret); } + else + { + Copy(secret, existingSecret); + SetNormalizedName(dbContext, existingSecret); + SecretSerialization.StoreSerializedProperties(dbContext, existingSecret); + } + + await SaveChangesAsync(dbContext, secret.Name, cancellationToken); } private static void Copy(Secret source, Secret target) @@ -127,8 +108,55 @@ public class EFCoreSecretRepository(Store store) : private static Task FindByNameAsync(SecretsElsaDbContext dbContext, string name, CancellationToken cancellationToken) { var normalizedName = NormalizeName(name); - return dbContext.Secrets.FirstOrDefaultAsync(x => x.Name.ToLower() == normalizedName, cancellationToken); + return dbContext.Secrets.FirstOrDefaultAsync(x => EF.Property(x, SecretConfiguration.NormalizedNamePropertyName) == normalizedName, cancellationToken); } - private static string NormalizeName(string name) => name.ToLowerInvariant(); + private static Task ExistsByNormalizedNameAsync(SecretsElsaDbContext dbContext, string normalizedName, CancellationToken cancellationToken) + { + return dbContext.Secrets.AnyAsync(x => EF.Property(x, SecretConfiguration.NormalizedNamePropertyName) == normalizedName, cancellationToken); + } + + private async Task SaveChangesAsync(SecretsElsaDbContext dbContext, string name, CancellationToken cancellationToken) + { + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException e) + { + if (await IsNameConflictAsync(name, cancellationToken)) + throw new InvalidOperationException($"A secret named '{name}' already exists.", e); + + throw; + } + } + + private async Task TrySaveChangesAsync(SecretsElsaDbContext dbContext, string name, CancellationToken cancellationToken) + { + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException) + { + if (await IsNameConflictAsync(name, cancellationToken)) + return false; + + throw; + } + } + + private async Task IsNameConflictAsync(string name, CancellationToken cancellationToken) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + return await ExistsByNormalizedNameAsync(dbContext, NormalizeName(name), cancellationToken); + } + + private static void SetNormalizedName(SecretsElsaDbContext dbContext, Secret secret) + { + dbContext.Entry(secret).Property(SecretConfiguration.NormalizedNamePropertyName).CurrentValue = NormalizeName(secret.Name); + } + + private static string NormalizeName(string name) => name.Trim().ToLowerInvariant(); } diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs b/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs index e3f31616e..46ea12dea 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs @@ -6,6 +6,8 @@ namespace Elsa.Secrets.Persistence.EFCore; internal class SecretConfiguration : IEntityTypeConfiguration { + public const string NormalizedNamePropertyName = "NormalizedName"; + public void Configure(EntityTypeBuilder builder) { builder.HasKey(x => x.Id); @@ -15,12 +17,13 @@ internal class SecretConfiguration : IEntityTypeConfiguration builder.Property(SecretSerialization.SerializedTagsPropertyName).HasColumnName("Tags").IsRequired(); builder.Property(SecretSerialization.SerializedVersionsPropertyName).HasColumnName("Versions").IsRequired(); builder.Property(x => x.Name).HasMaxLength(200).IsRequired(); + builder.Property(NormalizedNamePropertyName).HasMaxLength(200).IsRequired(); builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired(); builder.Property(x => x.TypeName).HasMaxLength(100).IsRequired(); builder.Property(x => x.StoreName).HasMaxLength(100).IsRequired(); builder.Property(x => x.Scope).HasMaxLength(200); builder.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); - builder.HasIndex(x => x.Name).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.Name)}").IsUnique(); + builder.HasIndex(NormalizedNamePropertyName).HasDatabaseName($"IX_{nameof(Secret)}_{NormalizedNamePropertyName}").IsUnique(); builder.HasIndex(x => x.TypeName).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.TypeName)}"); builder.HasIndex(x => x.StoreName).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.StoreName)}"); builder.HasIndex(x => x.Scope).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.Scope)}"); diff --git a/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs b/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs index 1f5e560b4..423192179 100644 --- a/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs +++ b/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs @@ -92,11 +92,14 @@ public class EFCoreSecretRepositoryTests : IAsyncLifetime await repository.AddAsync(new Secret { Name = "SMTP:PASSWORD", DisplayName = "SMTP password" }); var reloaded = await repository.GetAsync("smtp:password"); + var whitespaceReloaded = await repository.GetAsync(" SMTP:PASSWORD "); var activeReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "smtp:password", DisplayName = "Replacement password" }); var duplicateException = await Assert.ThrowsAsync(() => repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "Duplicate password" })); Assert.NotNull(reloaded); + Assert.NotNull(whitespaceReloaded); Assert.Equal("SMTP:PASSWORD", reloaded.Name); + Assert.Equal(reloaded.Id, whitespaceReloaded.Id); Assert.False(activeReplacementResult); Assert.Equal("A secret named 'smtp:password' already exists.", duplicateException.Message); }