From 440ffaf719d3a4aeddbf54c042741e0c393956ca Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 14 Sep 2026 07:03:16 +0200 Subject: [PATCH] test(secrets): add File/InMemory/EF tenancy conformance matrix (#8143) * test(secrets): add File/InMemory/EF tenancy conformance matrix Shared ISecretRepository scenarios for per-tenant uniqueness, Get/List isolation, deleted replace, and default-tenant duplicate rejection. Co-authored-by: Sipke Schoorstra * fix(secrets): enforce default-tenant uniqueness via empty TenantId Stamp leftover null TenantId values to "" before rebuilding the per-tenant unique index, matching Labels. Fail loud on leftover duplicate names instead of silently deduping. New EF writes in the default tenant persist "". Co-authored-by: Sipke Schoorstra * fix(secrets): make default-tenant uniqueness migrate on SQLite Use the schema-less Secrets table name, let CreateIndex fail loudly when leftover duplicates remain, and isolate the tenant-aware EF model cache from the non-tenant fixture. Co-authored-by: Sipke Schoorstra * test(secrets): avoid Path.Combine drop in uniqueness migration tests Reject rooted provider project paths and use Path.Join when locating Elsa.sln so CodeQL does not warn about discarded path segments. Co-authored-by: Sipke Schoorstra * fix(secrets): enforce Oracle default-tenant uniqueness via NVL index Oracle stores '' as NULL, so a TenantId IS NOT NULL filtered unique index left default-tenant secret names uncovered. Use NVL(TenantId, CHR(1)) so those rows share one index key. Stamp stays for preflight grouping; leftover duplicates still fail loud with no silent dedupe. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- doc/migrations/secrets-tenancy.md | 48 +++--- ..._SecretDefaultTenantUniqueness.Designer.cs | 113 +++++++++++++ ...914120000_SecretDefaultTenantUniqueness.cs | 87 ++++++++++ ..._SecretDefaultTenantUniqueness.Designer.cs | 113 +++++++++++++ ...914120000_SecretDefaultTenantUniqueness.cs | 98 +++++++++++ .../SecretsElsaDbContextModelSnapshot.cs | 3 +- ..._SecretDefaultTenantUniqueness.Designer.cs | 113 +++++++++++++ ...914120000_SecretDefaultTenantUniqueness.cs | 77 +++++++++ ..._SecretDefaultTenantUniqueness.Designer.cs | 114 +++++++++++++ ...914120000_SecretDefaultTenantUniqueness.cs | 89 ++++++++++ ..._SecretDefaultTenantUniqueness.Designer.cs | 109 ++++++++++++ ...914120000_SecretDefaultTenantUniqueness.cs | 61 +++++++ .../Repositories/EFCoreSecretRepository.cs | 19 +++ .../SecretConfiguration.cs | 8 +- .../EFCoreSecretRepositoryTests.cs | 40 ++++- ...etDefaultTenantUniquenessMigrationTests.cs | 94 +++++++++++ .../SecretRepositoryConformanceTests.cs | 155 ++++++++++++++++++ .../SecretRepositoryScenario.cs | 134 +++++++++++++++ 18 files changed, 1440 insertions(+), 35 deletions(-) create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs create mode 100644 src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs create mode 100644 test/unit/Elsa.Secrets.UnitTests/SecretDefaultTenantUniquenessMigrationTests.cs create mode 100644 test/unit/Elsa.Secrets.UnitTests/SecretRepositoryConformanceTests.cs create mode 100644 test/unit/Elsa.Secrets.UnitTests/SecretRepositoryScenario.cs diff --git a/doc/migrations/secrets-tenancy.md b/doc/migrations/secrets-tenancy.md index 5b7f4112a..d33c64139 100644 --- a/doc/migrations/secrets-tenancy.md +++ b/doc/migrations/secrets-tenancy.md @@ -9,8 +9,10 @@ against which tenant owns the secret, so any caller holding it reached the whole ## What you have to do -Apply the `SecretTenancy` migration for your provider. There is no data step, and nothing else is required for -a single-tenant deployment. +Apply the `SecretTenancy` and `SecretDefaultTenantUniqueness` migrations for your provider. The second +migration stamps leftover null `TenantId` values to `""` (the default tenant) and rebuilds the unique +index. It does **not** delete duplicate names — if two leftover rows share a name in the same tenant, +the upgrade aborts and lists the keys. Reconcile those rows, then re-run. ## Existing secrets @@ -46,32 +48,28 @@ the first tenant to claim a name took it globally. Downgrading recreates the global unique index and **will fail if two tenants hold the same secret name by then**. Reconcile the duplicates first. -### Null-tenant rows sit outside the index +### Default-tenant rows use `""` -"Unique per tenant" is enforced by the database only for rows whose `TenantId` is non-null. SQL Server -creates the composite index with a `[TenantId] IS NOT NULL` filter, and SQLite, PostgreSQL and MySQL treat -nulls as distinct in unique indexes — either way, null-tenant rows never collide in it. Only Oracle, where -equal nulls do count as duplicates in a composite unique index, still rejects them. +The default tenant id is `""` (`Tenant.DefaultTenantId`), the same sentinel Labels uses. New EF writes that +would have left `TenantId` null now persist `""`, so they participate in `(TenantId, NormalizedName)`. -This matters more than it sounds, because null is the common case. With multitenancy **disabled** — the -default single-tenant deployment — nothing ever assigns a `TenantId`, so every row keeps null and the schema -no longer enforces secret-name uniqueness at all. Uniqueness then rests on the repository's read-before-write -check, which blocks sequential duplicates but not two concurrent creates racing past it. The old global index -was the backstop for exactly that race; accepting its loss for null rows is a consequence of the no-backfill -decision above. The same gap applies in a multi-tenant deployment's default tenant: pre-upgrade null rows and -new `""`-tenant rows are distinct index keys, so the index cannot stop a new default-tenant secret from -colliding by name with a legacy row. +`SecretDefaultTenantUniqueness` stamps leftover nulls from `SecretTenancy` to `""` and rebuilds that unique +index. SQL Server still creates the composite index with a `TenantId IS NOT NULL` filter; after the stamp +those rows are non-null, so they collide in it. SQLite, PostgreSQL and MySQL treat nulls as distinct — the +stamp is what brings them into the index. -Backfilling `TenantId` to `""` yourself does **not** restore the database guarantee, and it is worth being -precise about why. The backfill indexes the rows that exist when you run it, but nothing changes what happens -afterwards: with multitenancy disabled no `TenantId` is ever assigned, so every subsequent write still lands -as a null row outside the index. Two concurrent creates can still both pass the repository's read-before-write -check and commit the same name. Restoring the guarantee for real would mean making disabled-mode writes use -the same non-null sentinel the index is built on — Elsa does not do that, and the backfill alone does not -substitute for it. Until it does, treat the read-before-write check as the only protection in single-tenant -mode and serialize secret creation if you cannot tolerate the race. (The `SetTenantIdFilter` -null-compatibility clause keeps backfilled and straggler rows visible either way, so a backfill is still -useful for de-duplicating what you already have.) +Oracle is different: it stores `''` as `NULL`, so the stamp is a no-op and a `TenantId IS NOT NULL` filter +would leave every default-tenant row outside the index. That provider creates a function-based unique index +on `NVL(TenantId, CHR(1))` plus `NormalizedName` instead. `CHR(1)` is not a tenant id; it only exists so +NULL default-tenant names share one index key. The EF model snapshot cannot express `NVL`, so it looks like +the other unfiltered unique indexes; the physical Oracle index is the function-based one from this migration. + +The migration does not delete duplicates: it lists leftover `(TenantId, NormalizedName)` keys and aborts, +then the unique index fails loudly if any remain. + +`SetTenantIdFilter`'s null-compatibility clause still treats a stray null as the default tenant, so a row +that somehow remains unstamped stays visible there. File and InMemory already treat null and `""` as the +same tenant for uniqueness. ## The MySQL provider ships for net8.0 and net9.0 only diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs new file mode 100644 index 000000000..ea80de906 --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs @@ -0,0 +1,113 @@ +// +using System; +using Elsa.Secrets.Persistence.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets +{ + [DbContext(typeof(SecretsElsaDbContext))] + [Migration("20260914120000_SecretDefaultTenantUniqueness")] + partial class SecretDefaultTenantUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Elsa.Secrets.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SerializedTags") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("Tags"); + + b.Property("SerializedVersions") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("Versions"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("StoreName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("TenantId") + .HasColumnType("varchar(255)"); + + b.Property("TypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("IX_Secret_Scope"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Secret_Status"); + + b.HasIndex("StoreName") + .HasDatabaseName("IX_Secret_StoreName"); + + b.HasIndex("TypeName") + .HasDatabaseName("IX_Secret_TypeName"); + + b.HasIndex("TenantId", "NormalizedName") + .IsUnique() + .HasDatabaseName("IX_Secret_TenantId_NormalizedName"); + + b.ToTable("Secrets", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs new file mode 100644 index 000000000..bff67db1c --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets +{ + /// + public partial class SecretDefaultTenantUniqueness : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public SecretDefaultTenantUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + // Default tenant is "" (not null). Stamp leftover nulls so the unique index covers them. + migrationBuilder.Sql($""" + UPDATE `{_schema.Schema}`.`Secrets` + SET `TenantId` = '' + WHERE `TenantId` IS NULL; + """); + + // No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading. + // SIGNAL MESSAGE_TEXT is limited to 128 characters, so the listed keys are truncated. + migrationBuilder.Sql($""" + SET @duplicate_keys := ( + SELECT GROUP_CONCAT(CONCAT('(', IFNULL(`TenantId`, ''), ', ', `NormalizedName`, ')') SEPARATOR '; ') + FROM ( + SELECT `TenantId`, `NormalizedName` + FROM `{_schema.Schema}`.`Secrets` + GROUP BY `TenantId`, `NormalizedName` + HAVING COUNT(*) > 1 + ) AS Duplicates + ); + + SET @sql := IF( + @duplicate_keys IS NOT NULL, + CONCAT( + 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''', + LEFT(REPLACE(CONCAT( + 'Duplicate keys. Resolve before upgrade: ', + @duplicate_keys + ), '''', ''), 128), + '''' + ), + 'SELECT 1' + ); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + """); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true); + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs new file mode 100644 index 000000000..71a875241 --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs @@ -0,0 +1,113 @@ +// +using System; +using Elsa.Secrets.Persistence.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Oracle.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets +{ + [DbContext(typeof(SecretsElsaDbContext))] + [Migration("20260914120000_SecretDefaultTenantUniqueness")] + partial class SecretDefaultTenantUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Elsa.Secrets.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("NVARCHAR2(450)"); + + b.Property("CreatedAt") + .HasColumnType("TIMESTAMP(7) WITH TIME ZONE"); + + b.Property("Description") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("SerializedTags") + .IsRequired() + .HasColumnType("NCLOB") + .HasColumnName("Tags"); + + b.Property("SerializedVersions") + .IsRequired() + .HasColumnType("NCLOB") + .HasColumnName("Versions"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("NVARCHAR2(32)"); + + b.Property("StoreName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("TenantId") + .HasColumnType("NVARCHAR2(450)"); + + b.Property("TypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("UpdatedAt") + .HasColumnType("TIMESTAMP(7) WITH TIME ZONE"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("IX_Secret_Scope"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Secret_Status"); + + b.HasIndex("StoreName") + .HasDatabaseName("IX_Secret_StoreName"); + + b.HasIndex("TypeName") + .HasDatabaseName("IX_Secret_TypeName"); + + b.HasIndex("TenantId", "NormalizedName") + .IsUnique() + .HasDatabaseName("IX_Secret_TenantId_NormalizedName"); + + b.ToTable("Secrets", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs new file mode 100644 index 000000000..ff94cfe76 --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets +{ + /// + public partial class SecretDefaultTenantUniqueness : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public SecretDefaultTenantUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + // Oracle persists '' as NULL, so this stamp is a no-op. It is kept so leftover + // default-tenant rows stay in one GROUP BY bucket for the fail-loud preflight. + // Uniqueness is the NVL unique index below, not a TenantId IS NOT NULL filter. + migrationBuilder.Sql($""" + UPDATE "{_schema.Schema}"."Secrets" + SET "TenantId" = '' + WHERE "TenantId" IS NULL; + """); + + // No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading. + migrationBuilder.Sql($""" + DECLARE + duplicate_keys VARCHAR2(4000); + duplicate_count NUMBER; + BEGIN + SELECT COUNT(*) + INTO duplicate_count + FROM ( + SELECT "TenantId", "NormalizedName" + FROM "{_schema.Schema}"."Secrets" + GROUP BY "TenantId", "NormalizedName" + HAVING COUNT(*) > 1 + ); + + IF duplicate_count > 0 THEN + SELECT LISTAGG('(' || NVL("TenantId", '') || ', ' || "NormalizedName" || ')', '; ' ON OVERFLOW TRUNCATE) + WITHIN GROUP (ORDER BY "NormalizedName") + INTO duplicate_keys + FROM ( + SELECT "TenantId", "NormalizedName" + FROM "{_schema.Schema}"."Secrets" + GROUP BY "TenantId", "NormalizedName" + HAVING COUNT(*) > 1 + ); + + RAISE_APPLICATION_ERROR( + -20001, + SUBSTR( + 'Cannot create unique index IX_Secret_TenantId_NormalizedName because leftover duplicate (TenantId, NormalizedName) rows exist. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: ' || duplicate_keys, + 1, + 2048 + ) + ); + END IF; + END; + """); + + // CHR(1) is not a tenant id. NVL maps Oracle's NULL (from '') onto one index key + // so two default-tenant rows with the same NormalizedName collide. + migrationBuilder.Sql($""" + CREATE UNIQUE INDEX "{_schema.Schema}"."IX_Secret_TenantId_NormalizedName" + ON "{_schema.Schema}"."Secrets" (NVL("TenantId", CHR(1)), "NormalizedName"); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true, + filter: "\"TenantId\" IS NOT NULL"); + } + } +} 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 babf70075..f365b296a 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 @@ -100,8 +100,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets b.HasIndex("TenantId", "NormalizedName") .IsUnique() - .HasDatabaseName("IX_Secret_TenantId_NormalizedName") - .HasFilter("\"TenantId\" IS NOT NULL"); + .HasDatabaseName("IX_Secret_TenantId_NormalizedName"); b.ToTable("Secrets", "Elsa"); }); diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs new file mode 100644 index 000000000..b3976f33f --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs @@ -0,0 +1,113 @@ +// +using System; +using Elsa.Secrets.Persistence.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets +{ + [DbContext(typeof(SecretsElsaDbContext))] + [Migration("20260914120000_SecretDefaultTenantUniqueness")] + partial class SecretDefaultTenantUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Elsa.Secrets.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .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)"); + + b.Property("SerializedTags") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Tags"); + + b.Property("SerializedVersions") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Versions"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StoreName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("text"); + + b.Property("TypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("IX_Secret_Scope"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Secret_Status"); + + b.HasIndex("StoreName") + .HasDatabaseName("IX_Secret_StoreName"); + + b.HasIndex("TypeName") + .HasDatabaseName("IX_Secret_TypeName"); + + b.HasIndex("TenantId", "NormalizedName") + .IsUnique() + .HasDatabaseName("IX_Secret_TenantId_NormalizedName"); + + b.ToTable("Secrets", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs new file mode 100644 index 000000000..c6827765e --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets +{ + /// + public partial class SecretDefaultTenantUniqueness : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public SecretDefaultTenantUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + // Default tenant is "" (not null). Stamp leftover nulls so the unique index covers them. + migrationBuilder.Sql($""" + UPDATE "{_schema.Schema}"."Secrets" + SET "TenantId" = '' + WHERE "TenantId" IS NULL; + """); + + // No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading. + migrationBuilder.Sql($""" + DO $$ + DECLARE keys text; + BEGIN + SELECT string_agg(format('(%s, %s)', "TenantId", "NormalizedName"), ', ') + INTO keys + FROM ( + SELECT "TenantId", "NormalizedName" + FROM "{_schema.Schema}"."Secrets" + GROUP BY "TenantId", "NormalizedName" + HAVING COUNT(*) > 1 + ) d; + + IF keys IS NOT NULL THEN + RAISE EXCEPTION 'Cannot apply IX_Secret_TenantId_NormalizedName. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: %', keys; + END IF; + END $$; + """); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true); + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs new file mode 100644 index 000000000..8f87dae71 --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs @@ -0,0 +1,114 @@ +// +using System; +using Elsa.Secrets.Persistence.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets +{ + [DbContext(typeof(SecretsElsaDbContext))] + [Migration("20260914120000_SecretDefaultTenantUniqueness")] + partial class SecretDefaultTenantUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Elsa.Secrets.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SerializedTags") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("Tags"); + + b.Property("SerializedVersions") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("Versions"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("StoreName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(450)"); + + b.Property("TypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("IX_Secret_Scope"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Secret_Status"); + + b.HasIndex("StoreName") + .HasDatabaseName("IX_Secret_StoreName"); + + b.HasIndex("TypeName") + .HasDatabaseName("IX_Secret_TypeName"); + + b.HasIndex("TenantId", "NormalizedName") + .IsUnique() + .HasDatabaseName("IX_Secret_TenantId_NormalizedName") + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("Secrets", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs new file mode 100644 index 000000000..d9aa9d316 --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets +{ + /// + public partial class SecretDefaultTenantUniqueness : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public SecretDefaultTenantUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + // Default tenant is "" (not null). Stamp leftover nulls so the filtered unique + // index (TenantId IS NOT NULL) covers them. Duplicates are not deleted. + migrationBuilder.Sql($""" + UPDATE [{_schema.Schema}].[Secrets] + SET [TenantId] = N'' + WHERE [TenantId] IS NULL; + """); + + // No silent dedupe. List leftover keys and abort; operators must resolve them before upgrading. + migrationBuilder.Sql($""" + IF EXISTS ( + SELECT 1 + FROM [{_schema.Schema}].[Secrets] + GROUP BY [TenantId], [NormalizedName] + HAVING COUNT(*) > 1 + ) + BEGIN + DECLARE @DuplicateKeys nvarchar(max); + + SELECT @DuplicateKeys = STRING_AGG( + CAST(CONCAT(N'(', ISNULL([TenantId], N''), N', ', [NormalizedName], N')') AS nvarchar(max)), + N'; ' + ) + FROM ( + SELECT [TenantId], [NormalizedName] + FROM [{_schema.Schema}].[Secrets] + GROUP BY [TenantId], [NormalizedName] + HAVING COUNT(*) > 1 + ) AS Duplicates; + + THROW 50001, CONCAT( + N'Cannot create unique index IX_Secret_TenantId_NormalizedName because leftover duplicate (TenantId, NormalizedName) rows exist. Operators must resolve leftover (TenantId, NormalizedName) rows before upgrade. Duplicate keys: ', + LEFT(@DuplicateKeys, 1500) + ), 1; + END + """); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true, + filter: "[TenantId] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true, + filter: "[TenantId] IS NOT NULL"); + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs new file mode 100644 index 000000000..e495b51ee --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.Designer.cs @@ -0,0 +1,109 @@ +// +using Elsa.Secrets.Persistence.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets +{ + [DbContext(typeof(SecretsElsaDbContext))] + [Migration("20260914120000_SecretDefaultTenantUniqueness")] + partial class SecretDefaultTenantUniqueness + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Elsa.Secrets.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SerializedTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Tags"); + + b.Property("SerializedVersions") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Versions"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("IX_Secret_Scope"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Secret_Status"); + + b.HasIndex("StoreName") + .HasDatabaseName("IX_Secret_StoreName"); + + b.HasIndex("TypeName") + .HasDatabaseName("IX_Secret_TypeName"); + + b.HasIndex("TenantId", "NormalizedName") + .IsUnique() + .HasDatabaseName("IX_Secret_TenantId_NormalizedName"); + + b.ToTable("Secrets", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs new file mode 100644 index 000000000..6d349b50c --- /dev/null +++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260914120000_SecretDefaultTenantUniqueness.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets +{ + /// + public partial class SecretDefaultTenantUniqueness : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public SecretDefaultTenantUniqueness(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + // SQLite ignores schemas; the table is created as "Secrets". + const string table = "\"Secrets\""; + + // Default tenant is "" (not null). Stamp leftover nulls so the unique index covers them. + // Leftover duplicates fail at CreateIndex; SQLite cannot abort from a standalone SELECT. + migrationBuilder.Sql($""" + UPDATE {table} + SET "TenantId" = '' + WHERE "TenantId" IS NULL; + """); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets"); + + migrationBuilder.CreateIndex( + name: "IX_Secret_TenantId_NormalizedName", + schema: _schema.Schema, + table: "Secrets", + columns: new[] { "TenantId", "NormalizedName" }, + unique: true); + } + } +} diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs b/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs index 2f3a19980..4e5f75693 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore/Repositories/EFCoreSecretRepository.cs @@ -1,3 +1,4 @@ +using Elsa.Common.Multitenancy; using Elsa.Persistence.EFCore; using Elsa.Secrets.Contracts; using Elsa.Secrets.Models; @@ -29,6 +30,7 @@ public class EFCoreSecretRepository(Store store, I public async Task AddAsync(Secret secret, CancellationToken cancellationToken = default) { await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + AssignDefaultTenantId(secret, dbContext); var normalizedName = secretNameValidator.Normalize(secret.Name); if (await ExistsByNormalizedNameAsync(dbContext, normalizedName, cancellationToken)) throw new InvalidOperationException($"A secret named '{secret.Name}' already exists."); @@ -42,6 +44,7 @@ public class EFCoreSecretRepository(Store store, I public async Task TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default) { await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + AssignDefaultTenantId(secret, dbContext); var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken); if (existingSecret == null) @@ -72,6 +75,7 @@ public class EFCoreSecretRepository(Store store, I public async Task SaveAsync(Secret secret, CancellationToken cancellationToken = default) { await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + AssignDefaultTenantId(secret, dbContext); var existingSecret = await FindByNameAsync(dbContext, secret.Name, cancellationToken); if (existingSecret == null) @@ -163,4 +167,19 @@ public class EFCoreSecretRepository(Store store, I { dbContext.Entry(secret).Property(SecretShadowPropertyNames.NormalizedName).CurrentValue = secretNameValidator.Normalize(secret.Name); } + + /// + /// Default-tenant uniqueness uses (""), not null. + /// Named and agnostic ambient tenants are left for ApplyTenantId. + /// + private static void AssignDefaultTenantId(Secret secret, SecretsElsaDbContext dbContext) + { + if (secret.TenantId is not null) + return; + + if (!string.IsNullOrEmpty(dbContext.TenantId) && dbContext.TenantId != Tenant.DefaultTenantId) + return; + + secret.TenantId = Tenant.DefaultTenantId; + } } diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs b/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs index 9f3de5d39..f340ad9c5 100644 --- a/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs +++ b/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs @@ -21,9 +21,11 @@ internal class SecretConfiguration : IEntityTypeConfiguration builder.Property(x => x.StoreName).HasMaxLength(100).IsRequired(); builder.Property(x => x.Scope).HasMaxLength(200); builder.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); - // Uniqueness is per tenant, matching what User, Role and Application moved to in the same release. - // A global unique index would make the name a shared resource across tenants: the second tenant to - // want a secret called "smtp-password" simply could not create one. + // Uniqueness is per tenant, matching Labels (and User, Role, Application). + // A global unique index would make the name a shared resource across tenants. + // SQL Server still emits a filtered unique index (TenantId IS NOT NULL); leftover + // nulls are stamped to "" first. Oracle stores '' as NULL, so that provider uses + // a function-based unique index on NVL(TenantId, CHR(1)) instead of a filter. builder.HasIndex("TenantId", SecretShadowPropertyNames.NormalizedName).HasDatabaseName($"IX_{nameof(Secret)}_TenantId_{SecretShadowPropertyNames.NormalizedName}").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)}"); diff --git a/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs b/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs index ddc54ff25..27b6692dc 100644 --- a/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs +++ b/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs @@ -1,3 +1,4 @@ +using Elsa.Common.Multitenancy; using Elsa.Persistence.EFCore; using Elsa.Persistence.EFCore.Extensions; using Elsa.Secrets.Contracts; @@ -66,11 +67,10 @@ public class EFCoreSecretRepositoryTests : IAsyncLifetime } [Fact] - public async Task ExistingSecretsCarryNoTenantUntilOneIsAssigned() + public async Task DefaultTenantWritesUseEmptyTenantId() { - // The upgrade adds the column nullable with no backfill, so rows written before it stay null. That is - // what SetTenantIdFilter's "null counts as the default tenant" clause is for, and it is why the - // migration needs no data step. + // Default-tenant uniqueness uses "" so the composite unique index covers these rows. + // Leftover nulls from SecretTenancy are stamped by SecretDefaultTenantUniqueness. await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -78,7 +78,21 @@ public class EFCoreSecretRepositoryTests : IAsyncLifetime var stored = await repository.GetAsync("legacy:secret"); Assert.NotNull(stored); - Assert.Null(stored!.TenantId); + Assert.Equal(string.Empty, stored!.TenantId); + } + + [Fact] + public async Task UniqueIndexRejectsDuplicateDefaultTenantNames() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var dbContext = await factory.CreateDbContextAsync(); + + await InsertSecretAsync(dbContext, "first", "smtp:password", Tenant.DefaultTenantId); + await dbContext.SaveChangesAsync(); + + await InsertSecretAsync(dbContext, "second", "smtp:password", Tenant.DefaultTenantId); + await Assert.ThrowsAsync(() => dbContext.SaveChangesAsync()); } [Fact] @@ -160,4 +174,20 @@ public class EFCoreSecretRepositoryTests : IAsyncLifetime Assert.Equal("Replacement password", reloaded.DisplayName); Assert.Equal(SecretStatus.Active, reloaded.Status); } + + private static async Task InsertSecretAsync(SecretsElsaDbContext dbContext, string id, string name, string tenantId) + { + var secret = new Secret + { + Id = id, + Name = name, + DisplayName = name, + TenantId = tenantId + }; + + await dbContext.Secrets.AddAsync(secret); + dbContext.Entry(secret).Property(SecretShadowPropertyNames.NormalizedName).CurrentValue = name; + dbContext.Entry(secret).Property(SecretShadowPropertyNames.SerializedTags).CurrentValue = "[]"; + dbContext.Entry(secret).Property(SecretShadowPropertyNames.SerializedVersions).CurrentValue = "[]"; + } } diff --git a/test/unit/Elsa.Secrets.UnitTests/SecretDefaultTenantUniquenessMigrationTests.cs b/test/unit/Elsa.Secrets.UnitTests/SecretDefaultTenantUniquenessMigrationTests.cs new file mode 100644 index 000000000..2f07188cf --- /dev/null +++ b/test/unit/Elsa.Secrets.UnitTests/SecretDefaultTenantUniquenessMigrationTests.cs @@ -0,0 +1,94 @@ +namespace Elsa.Secrets.UnitTests; + +/// +/// SecretDefaultTenantUniqueness must stamp null TenantId to "" and must not +/// auto-delete duplicate secrets. A preflight lists leftover keys and aborts; +/// CreateIndex then fails loudly if any remain. SQL Server keeps the filtered +/// unique index (TenantId IS NOT NULL) after leftover nulls become "". Oracle +/// stores '' as NULL, so it uses NVL(TenantId, CHR(1)) instead of a filter. +/// +public class SecretDefaultTenantUniquenessMigrationTests +{ + [Theory] + [InlineData("Elsa.Secrets.Persistence.EFCore.Sqlite")] + [InlineData("Elsa.Secrets.Persistence.EFCore.SqlServer")] + [InlineData("Elsa.Secrets.Persistence.EFCore.PostgreSql")] + [InlineData("Elsa.Secrets.Persistence.EFCore.MySql")] + [InlineData("Elsa.Secrets.Persistence.EFCore.Oracle")] + public void SecretDefaultTenantUniqueness_StampsNullTenantIdAndDoesNotDeleteDuplicates(string providerProject) + { + var migration = FindMigration(providerProject); + + Assert.Contains("TenantId", migration, StringComparison.Ordinal); + Assert.Contains("IS NULL", migration, StringComparison.Ordinal); + Assert.DoesNotContain("DELETE FROM", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DeleteData", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("COALESCE", migration, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SecretDefaultTenantUniqueness_SqliteFailsLoudlyAtCreateIndex() + { + // SQLite cannot abort from a standalone SELECT, so CreateIndex unique is the fail-loud path. + var migration = FindMigration("Elsa.Secrets.Persistence.EFCore.Sqlite"); + Assert.DoesNotContain("RAISE", migration, StringComparison.Ordinal); + Assert.Contains("CreateIndex", migration, StringComparison.Ordinal); + Assert.Contains("unique: true", migration, StringComparison.Ordinal); + } + + [Theory] + [InlineData("Elsa.Secrets.Persistence.EFCore.SqlServer", "THROW")] + [InlineData("Elsa.Secrets.Persistence.EFCore.PostgreSql", "RAISE EXCEPTION")] + [InlineData("Elsa.Secrets.Persistence.EFCore.MySql", "SIGNAL")] + [InlineData("Elsa.Secrets.Persistence.EFCore.Oracle", "RAISE_APPLICATION_ERROR")] + public void SecretDefaultTenantUniqueness_PreflightsDuplicateKeys(string providerProject, string abortKeyword) + { + var migration = FindMigration(providerProject); + + Assert.Contains(abortKeyword, migration, StringComparison.Ordinal); + Assert.Contains("COUNT(*)", migration, StringComparison.Ordinal); + Assert.Contains("Duplicate keys", migration, StringComparison.OrdinalIgnoreCase); + Assert.Contains("HAVING COUNT(*) > 1", migration, StringComparison.Ordinal); + } + + [Fact] + public void SecretDefaultTenantUniqueness_SqlServerKeepsFilteredUniqueIndex() + { + var migration = FindMigration("Elsa.Secrets.Persistence.EFCore.SqlServer"); + Assert.Contains("[TenantId] IS NOT NULL", migration, StringComparison.Ordinal); + } + + [Fact] + public void SecretDefaultTenantUniqueness_OracleUsesNvlBecauseEmptyStringIsNull() + { + var migration = FindMigration("Elsa.Secrets.Persistence.EFCore.Oracle"); + Assert.Contains("NVL(\"TenantId\", CHR(1))", migration, StringComparison.Ordinal); + Assert.Contains("CREATE UNIQUE INDEX", migration, StringComparison.Ordinal); + } + + private static string FindMigration(string providerProject) + { + if (Path.IsPathRooted(providerProject)) + throw new ArgumentException("Provider project must be a relative path.", nameof(providerProject)); + + var repoRoot = FindRepoRoot(); + var secretsDir = Path.Combine(repoRoot, "src", "modules", providerProject, "Migrations", "Secrets"); + var path = Directory.GetFiles(secretsDir, "*SecretDefaultTenantUniqueness.cs") + .Single(file => !file.EndsWith(".Designer.cs", StringComparison.Ordinal)); + return File.ReadAllText(path); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Join(directory.FullName, "Elsa.sln"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not find Elsa.sln from the test output directory."); + } +} diff --git a/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryConformanceTests.cs b/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryConformanceTests.cs new file mode 100644 index 000000000..ffe9df6de --- /dev/null +++ b/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryConformanceTests.cs @@ -0,0 +1,155 @@ +using Elsa.Common.Multitenancy; +using Elsa.Secrets.Models; + +namespace Elsa.Secrets.UnitTests; + +/// +/// Shared File / InMemory / EF Core store-contract assertions for secret repositories. +/// Covers per-tenant name uniqueness and ambient-tenant isolation on the existing +/// surface. +/// +public abstract class SecretRepositoryConformanceTests +{ + protected abstract Task CreateScenarioAsync(); + + [Fact] + public async Task TwoTenantsCanOwnTheSameSecretName() + { + await using var scenario = await CreateScenarioAsync(); + + using (scenario.UseTenant("tenant-a")) + { + var secretA = CreateSecret("SMTP:PASSWORD", "Tenant A"); + await scenario.Repository.AddAsync(secretA); + + Assert.Equal("tenant-a", secretA.TenantId); + await Assert.ThrowsAsync(() => + scenario.Repository.AddAsync(CreateSecret("smtp:password", "Duplicate A"))); + } + + using (scenario.UseTenant("tenant-b")) + { + var secretB = CreateSecret("smtp:password", "Tenant B"); + await scenario.Repository.AddAsync(secretB); + + Assert.Equal("tenant-b", secretB.TenantId); + Assert.Equal("Tenant B", (await scenario.Repository.GetAsync("SMTP:PASSWORD"))!.DisplayName); + } + + using (scenario.UseTenant("tenant-a")) + Assert.Equal("Tenant A", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName); + } + + [Fact] + public async Task GetDoesNotCrossReadAnotherTenant() + { + await using var scenario = await CreateScenarioAsync(); + + using (scenario.UseTenant("tenant-a")) + await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant A")); + + using (scenario.UseTenant("tenant-b")) + { + Assert.Null(await scenario.Repository.GetAsync("smtp:password")); + await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant B")); + } + + using (scenario.UseTenant("tenant-a")) + { + var loaded = await scenario.Repository.GetAsync("SMTP:PASSWORD"); + Assert.NotNull(loaded); + Assert.Equal("Tenant A", loaded.DisplayName); + Assert.Equal("tenant-a", loaded.TenantId); + } + } + + [Fact] + public async Task ListIsTenantScoped() + { + await using var scenario = await CreateScenarioAsync(); + + using (scenario.UseTenant("tenant-a")) + { + await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant A")); + await scenario.Repository.AddAsync(CreateSecret("api:key", "Key A")); + } + + using (scenario.UseTenant("tenant-b")) + { + await scenario.Repository.AddAsync(CreateSecret("smtp:password", "Tenant B")); + var listedB = await scenario.Repository.ListAsync(); + Assert.Equal("Tenant B", Assert.Single(listedB).DisplayName); + } + + using (scenario.UseTenant("tenant-a")) + { + var listedA = (await scenario.Repository.ListAsync()).OrderBy(x => x.Name).ToList(); + Assert.Equal(["api:key", "smtp:password"], listedA.Select(x => x.Name).ToList()); + Assert.All(listedA, secret => Assert.Equal("tenant-a", secret.TenantId)); + } + } + + [Fact] + public async Task DeletedReplaceStaysPerTenant() + { + await using var scenario = await CreateScenarioAsync(); + + using (scenario.UseTenant("tenant-a")) + { + await scenario.Repository.SaveAsync(CreateSecret("smtp:password", "Deleted A", SecretStatus.Deleted)); + Assert.True(await scenario.Repository.TryAddOrReplaceDeletedAsync(CreateSecret("SMTP:PASSWORD", "Replacement A"))); + Assert.Equal("Replacement A", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName); + } + + using (scenario.UseTenant("tenant-b")) + { + Assert.True(await scenario.Repository.TryAddOrReplaceDeletedAsync(CreateSecret("smtp:password", "Tenant B"))); + Assert.Equal("Tenant B", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName); + } + + using (scenario.UseTenant("tenant-a")) + Assert.Equal("Replacement A", (await scenario.Repository.GetAsync("smtp:password"))!.DisplayName); + } + + [Fact] + public async Task DefaultTenantRejectsDuplicateNames() + { + await using var scenario = await CreateScenarioAsync(); + + using (scenario.UseTenant(Tenant.DefaultTenantId)) + { + var secret = CreateSecret("smtp:password", "Default"); + await scenario.Repository.AddAsync(secret); + Assert.Equal(Tenant.DefaultTenantId, secret.TenantId); + + await Assert.ThrowsAsync(() => + scenario.Repository.AddAsync(CreateSecret("SMTP:PASSWORD", "Duplicate"))); + } + } + + private static Secret CreateSecret(string name, string displayName, SecretStatus status = SecretStatus.Active) => + new() + { + Name = name, + DisplayName = displayName, + Status = status + }; +} + +public sealed class InMemorySecretRepositoryConformanceTests : SecretRepositoryConformanceTests +{ + protected override Task CreateScenarioAsync() => + SecretRepositoryScenario.CreateInMemoryAsync(); +} + +public sealed class FileSecretRepositoryConformanceTests : SecretRepositoryConformanceTests +{ + protected override Task CreateScenarioAsync() => + SecretRepositoryScenario.CreateFileAsync(); +} + +public sealed class SqliteSecretRepositoryConformanceTests : SecretRepositoryConformanceTests +{ + protected override Task CreateScenarioAsync() => + SecretRepositoryScenario.CreateSqliteAsync(); +} diff --git a/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryScenario.cs b/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryScenario.cs new file mode 100644 index 000000000..cf60b38ec --- /dev/null +++ b/test/unit/Elsa.Secrets.UnitTests/SecretRepositoryScenario.cs @@ -0,0 +1,134 @@ +using Elsa.Common.Multitenancy; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.EntityHandlers; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Secrets.Contracts; +using Elsa.Secrets.Models; +using Elsa.Secrets.Options; +using Elsa.Secrets.Persistence.EFCore; +using Elsa.Secrets.Persistence.EFCore.Repositories; +using Elsa.Secrets.Persistence.EFCore.Sqlite.Extensions; +using Elsa.Secrets.Repositories; +using Elsa.Secrets.Services; +using Elsa.Tenants.Options; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Secrets.UnitTests; + +/// +/// One File, InMemory, or EF/SQLite plus the ambient tenant it reads. +/// +public sealed class SecretRepositoryScenario( + ITenantAccessor tenantAccessor, + ISecretRepository repository, + Func disposeAsync) : IAsyncDisposable +{ + public ITenantAccessor TenantAccessor { get; } = tenantAccessor; + public ISecretRepository Repository { get; } = repository; + + public IDisposable UseTenant(string tenantId) => + TenantAccessor.PushContext(tenantId == Tenant.DefaultTenantId + ? Tenant.Default + : new Tenant + { + Id = tenantId, + Name = tenantId + }); + + public ValueTask DisposeAsync() => disposeAsync(); + + public static Task CreateInMemoryAsync() + { + var tenantAccessor = new DefaultTenantAccessor(); + return Task.FromResult(new SecretRepositoryScenario( + tenantAccessor, + new InMemorySecretRepository(tenantAccessor), + () => ValueTask.CompletedTask)); + } + + public static Task CreateFileAsync() + { + var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-conformance-{Guid.NewGuid():N}.json"); + var tenantAccessor = new DefaultTenantAccessor(); + var options = Microsoft.Extensions.Options.Options.Create(new SecretsOptions + { + RepositoryFilePath = path + }); + + return Task.FromResult(new SecretRepositoryScenario( + tenantAccessor, + new FileSecretRepository(options, tenantAccessor: tenantAccessor), + () => + { + if (File.Exists(path)) + File.Delete(path); + return ValueTask.CompletedTask; + })); + } + + public static async Task CreateSqliteAsync() + { + var databasePath = Path.Join(Path.GetTempPath(), $"elsa-secrets-conformance-{Guid.NewGuid():N}.db"); + var tenantAccessor = new DefaultTenantAccessor(); + ServiceProvider? services = null; + IServiceScope? scope = null; + + try + { + var migrationsAssembly = typeof(SqliteSecretsPersistenceFeatureExtensions).Assembly; + services = new ServiceCollection() + .AddSingleton(tenantAccessor) + .Configure(options => options.IsEnabled = true) + .AddScoped() + .AddScoped() + .AddSqliteEntityModelCreatingHandlers() + .AddDbContextFactory((_, builder) => + builder.UseElsaSqlite(migrationsAssembly, $"Data Source={databasePath}") + .ReplaceService()) + .Decorate, TenantAwareDbContextFactory>() + .AddSingleton() + .AddScoped>() + .AddScoped() + .BuildServiceProvider(); + + await using (var dbContext = await services.GetRequiredService>().CreateDbContextAsync()) + await dbContext.Database.MigrateAsync(); + + scope = services.CreateScope(); + return new( + tenantAccessor, + scope.ServiceProvider.GetRequiredService(), + async () => + { + scope.Dispose(); + await services.DisposeAsync(); + SqliteConnection.ClearAllPools(); + if (File.Exists(databasePath)) + File.Delete(databasePath); + }); + } + catch + { + scope?.Dispose(); + if (services is not null) + await services.DisposeAsync(); + SqliteConnection.ClearAllPools(); + if (File.Exists(databasePath)) + File.Delete(databasePath); + throw; + } + } +} + +/// +/// Keeps the tenant-aware Secrets model out of the default EF cache shared with +/// EFCoreSecretRepositoryTests, which builds the same context type without a query filter. +/// +file sealed class TenantAwareSecretModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) => + (context.GetType(), designTime, "secrets-tenant-aware"); +}