diff --git a/doc/migrations/secrets-tenancy.md b/doc/migrations/secrets-tenancy.md
new file mode 100644
index 000000000..4aa9a5b3c
--- /dev/null
+++ b/doc/migrations/secrets-tenancy.md
@@ -0,0 +1,63 @@
+# Secrets become tenant-scoped
+
+`Secret` had no notion of tenancy. It did not derive from `Elsa.Common.Entities.Entity`, so it carried no
+`TenantId` and no query filter applied to it — in a multi-tenant deployment every tenant could see and resolve
+every other tenant's secrets. Permissions did not help: `secrets:view` is evaluated against the caller, not
+against which tenant owns the secret, so any caller holding it reached the whole set.
+
+`Secret` now derives from `Entity` and is filtered like every other user-facing entity.
+
+## 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.
+
+## Existing secrets
+
+The migration adds `TenantId` **nullable and does not backfill it**, so rows written before the upgrade keep a
+null tenant. That is deliberate rather than an omission: `SetTenantIdFilter` already treats a null `TenantId`
+as belonging to the default tenant, through a clause written for exactly this case.
+
+```
+TenantId == context.TenantId || TenantId == "*" || (TenantId == null && context.TenantId == "")
+```
+
+What that means for you:
+
+| Deployment | Existing secrets after upgrade |
+| --- | --- |
+| Single-tenant | Visible and unchanged. The filter is only installed when multitenancy is enabled, so nothing applies at all. |
+| Multi-tenant | **Not visible** to any named tenant. Assign each secret to its owning tenant, or set `TenantId` to `*` to share it across all of them. |
+
+The multi-tenant case is a deliberate, visible failure. The alternative — leaving every pre-existing secret
+readable from every tenant — is the exposure this change exists to close.
+
+## Shared platform secrets
+
+Set `TenantId` to `*` (`Tenant.AgnosticTenantId`, per ADR 0009) for a secret every tenant should resolve, such
+as a platform-wide SMTP credential. Agnostic secrets are visible from every tenant context.
+
+## Secret names are now unique per tenant
+
+The unique index moves from `NormalizedName` to `(TenantId, NormalizedName)`, matching what `User`, `Role` and
+`Application` did in the same release. Two tenants may now each hold a secret called `smtp-password`; before,
+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.
+
+## The VNext persistence provider does not support this
+
+`Elsa.Secrets.Persistence.VNext` stores documents keyed by secret name alone, and `Elsa.Persistence.VNext` has
+no tenant concept to filter on. Rather than silently serve one tenant's secret to another, it now throws when
+used outside the default tenant context. If you run multitenancy, use an Entity Framework Core secrets
+provider. Single-tenant deployments are unaffected.
+
+Making it tenant-aware means changing the document id scheme, which relocates existing documents — a storage
+change to make deliberately rather than fold into this one.
+
+## Configuration-backed secrets
+
+`ConfigurationSecretStore` reads values from application configuration and stores only a key. The value stays
+deployment-level and is not partitioned, but the `Secret` record describing it is an ordinary row and is
+tenant-scoped like any other. Two tenants may each hold a record pointing at the same configuration key.
diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260825225931_SecretTenancy.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260825225931_SecretTenancy.Designer.cs
new file mode 100644
index 000000000..375ee1ec6
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260825225931_SecretTenancy.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("20260825225931_SecretTenancy")]
+ partial class SecretTenancy
+ {
+ ///
+ 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/20260825225931_SecretTenancy.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260825225931_SecretTenancy.cs
new file mode 100644
index 000000000..fdc4c4d75
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.MySql/Migrations/Secrets/20260825225931_SecretTenancy.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
+{
+ ///
+ public partial class SecretTenancy : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.AddColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets",
+ type: "varchar(255)",
+ nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ columns: new[] { "TenantId", "NormalizedName" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.DropColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ column: "NormalizedName",
+ unique: true);
+ }
+ }
+}
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 3f5ae005c..a2a82dcc0 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
@@ -18,7 +18,7 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
- .HasAnnotation("ProductVersion", "9.0.16")
+ .HasAnnotation("ProductVersion", "9.0.17")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@@ -73,6 +73,9 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
.HasMaxLength(100)
.HasColumnType("varchar(100)");
+ b.Property("TenantId")
+ .HasColumnType("varchar(255)");
+
b.Property("TypeName")
.IsRequired()
.HasMaxLength(100)
@@ -83,10 +86,6 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
b.HasKey("Id");
- b.HasIndex("NormalizedName")
- .IsUnique()
- .HasDatabaseName("IX_Secret_NormalizedName");
-
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");
@@ -99,6 +98,10 @@ namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
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/20260825225141_SecretTenancy.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260825225141_SecretTenancy.Designer.cs
new file mode 100644
index 000000000..17ace5dee
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260825225141_SecretTenancy.Designer.cs
@@ -0,0 +1,114 @@
+//
+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("20260825225141_SecretTenancy")]
+ partial class SecretTenancy
+ {
+ ///
+ 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")
+ .HasFilter("\"TenantId\" IS NOT NULL");
+
+ b.ToTable("Secrets", "Elsa");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260825225141_SecretTenancy.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260825225141_SecretTenancy.cs
new file mode 100644
index 000000000..7f602348f
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Oracle/Migrations/Secrets/20260825225141_SecretTenancy.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
+{
+ ///
+ public partial class SecretTenancy : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.AddColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets",
+ type: "NVARCHAR2(450)",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ 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: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.DropColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ column: "NormalizedName",
+ unique: true);
+ }
+ }
+}
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 17051256f..babf70075 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
@@ -18,7 +18,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
- .HasAnnotation("ProductVersion", "9.0.16")
+ .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -73,6 +73,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
+ b.Property("TenantId")
+ .HasColumnType("NVARCHAR2(450)");
+
b.Property("TypeName")
.IsRequired()
.HasMaxLength(100)
@@ -83,10 +86,6 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
b.HasKey("Id");
- b.HasIndex("NormalizedName")
- .IsUnique()
- .HasDatabaseName("IX_Secret_NormalizedName");
-
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");
@@ -99,6 +98,11 @@ namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
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.PostgreSql/Migrations/Secrets/20260825224525_SecretTenancy.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260825224525_SecretTenancy.Designer.cs
new file mode 100644
index 000000000..d4a47edd4
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260825224525_SecretTenancy.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("20260825224525_SecretTenancy")]
+ partial class SecretTenancy
+ {
+ ///
+ 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/20260825224525_SecretTenancy.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260825224525_SecretTenancy.cs
new file mode 100644
index 000000000..99314b1df
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.PostgreSql/Migrations/Secrets/20260825224525_SecretTenancy.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
+{
+ ///
+ public partial class SecretTenancy : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.AddColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ columns: new[] { "TenantId", "NormalizedName" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.DropColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ column: "NormalizedName",
+ unique: true);
+ }
+ }
+}
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 d0febedf5..d2d358c56 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
@@ -18,7 +18,7 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
- .HasAnnotation("ProductVersion", "9.0.16")
+ .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -73,6 +73,9 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
.HasMaxLength(100)
.HasColumnType("character varying(100)");
+ b.Property("TenantId")
+ .HasColumnType("text");
+
b.Property("TypeName")
.IsRequired()
.HasMaxLength(100)
@@ -83,10 +86,6 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
b.HasKey("Id");
- b.HasIndex("NormalizedName")
- .IsUnique()
- .HasDatabaseName("IX_Secret_NormalizedName");
-
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");
@@ -99,6 +98,10 @@ namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
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.SqlServer/Migrations/Secrets/20260825230253_SecretTenancy.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260825230253_SecretTenancy.Designer.cs
new file mode 100644
index 000000000..c18edf49e
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260825230253_SecretTenancy.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("20260825230253_SecretTenancy")]
+ partial class SecretTenancy
+ {
+ ///
+ 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/20260825230253_SecretTenancy.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260825230253_SecretTenancy.cs
new file mode 100644
index 000000000..8bf010cb9
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.SqlServer/Migrations/Secrets/20260825230253_SecretTenancy.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
+{
+ ///
+ public partial class SecretTenancy : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.AddColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets",
+ type: "nvarchar(450)",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ 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: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.DropColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ column: "NormalizedName",
+ unique: true);
+ }
+ }
+}
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 4e800c148..52216cd75 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
@@ -18,7 +18,7 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
- .HasAnnotation("ProductVersion", "9.0.16")
+ .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -73,6 +73,9 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
+ b.Property("TenantId")
+ .HasColumnType("nvarchar(450)");
+
b.Property("TypeName")
.IsRequired()
.HasMaxLength(100)
@@ -83,10 +86,6 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
b.HasKey("Id");
- b.HasIndex("NormalizedName")
- .IsUnique()
- .HasDatabaseName("IX_Secret_NormalizedName");
-
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");
@@ -99,6 +98,11 @@ namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
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.Sqlite/Migrations/Secrets/20260825230122_SecretTenancy.Designer.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260825230122_SecretTenancy.Designer.cs
new file mode 100644
index 000000000..9c7543830
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260825230122_SecretTenancy.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("20260825230122_SecretTenancy")]
+ partial class SecretTenancy
+ {
+ ///
+ 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/20260825230122_SecretTenancy.cs b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260825230122_SecretTenancy.cs
new file mode 100644
index 000000000..4782c0e35
--- /dev/null
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore.Sqlite/Migrations/Secrets/20260825230122_SecretTenancy.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
+{
+ ///
+ public partial class SecretTenancy : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.AddColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ columns: new[] { "TenantId", "NormalizedName" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Secret_TenantId_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.DropColumn(
+ name: "TenantId",
+ schema: "Elsa",
+ table: "Secrets");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Secret_NormalizedName",
+ schema: "Elsa",
+ table: "Secrets",
+ column: "NormalizedName",
+ unique: true);
+ }
+ }
+}
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 ee77914fd..14d6e58a5 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
@@ -16,7 +16,7 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
- .HasAnnotation("ProductVersion", "9.0.16");
+ .HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("Elsa.Secrets.Models.Secret", b =>
{
@@ -69,6 +69,9 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
.HasMaxLength(100)
.HasColumnType("TEXT");
+ b.Property("TenantId")
+ .HasColumnType("TEXT");
+
b.Property("TypeName")
.IsRequired()
.HasMaxLength(100)
@@ -79,10 +82,6 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
b.HasKey("Id");
- b.HasIndex("NormalizedName")
- .IsUnique()
- .HasDatabaseName("IX_Secret_NormalizedName");
-
b.HasIndex("Scope")
.HasDatabaseName("IX_Secret_Scope");
@@ -95,6 +94,10 @@ namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
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/SecretConfiguration.cs b/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs
index ab3319d4e..9f3de5d39 100644
--- a/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs
+++ b/src/modules/Elsa.Secrets.Persistence.EFCore/SecretConfiguration.cs
@@ -21,7 +21,10 @@ 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();
- builder.HasIndex(SecretShadowPropertyNames.NormalizedName).HasDatabaseName($"IX_{nameof(Secret)}_{SecretShadowPropertyNames.NormalizedName}").IsUnique();
+ // 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.
+ 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)}");
builder.HasIndex(x => x.Scope).HasDatabaseName($"IX_{nameof(Secret)}_{nameof(Secret.Scope)}");
diff --git a/src/modules/Elsa.Secrets.Persistence.VNext/Repositories/VNextSecretRepository.cs b/src/modules/Elsa.Secrets.Persistence.VNext/Repositories/VNextSecretRepository.cs
index e8e764c65..7ac4d6772 100644
--- a/src/modules/Elsa.Secrets.Persistence.VNext/Repositories/VNextSecretRepository.cs
+++ b/src/modules/Elsa.Secrets.Persistence.VNext/Repositories/VNextSecretRepository.cs
@@ -1,13 +1,44 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using Elsa.Common.Multitenancy;
using Elsa.Persistence.VNext.Document;
using Elsa.Secrets.Contracts;
using Elsa.Secrets.Models;
namespace Elsa.Secrets.Persistence.VNext.Repositories;
-public class VNextSecretRepository(IDocumentStore documentStore) : ISecretRepository
+public class VNextSecretRepository(IDocumentStore documentStore, ITenantAccessor? tenantAccessor = null) : ISecretRepository
{
+ ///
+ /// Refuses to serve a request made in a non-default tenant context.
+ ///
+ ///
+ /// Secrets are tenant-scoped entities, and the EF Core providers enforce that through the query filter
+ /// every other entity uses. This provider inherits none of it: Elsa.Persistence.VNext has no tenant
+ /// concept, and documents here are keyed by secret name alone, so every tenant would read and overwrite
+ /// the same document. Making it tenant-aware means changing the document id scheme, which relocates
+ /// existing documents and is a storage change to make deliberately rather than fold into a tenancy fix.
+ ///
+ /// Until then this throws rather than quietly serving one tenant's secret to another. It is checked per
+ /// call rather than at startup so it catches a tenant context entered at runtime, and it stays silent for
+ /// the default tenant, which is every single-tenant deployment.
+ ///
+ ///
+ /// The accessor is optional because it is registered by the tenants module: a host that never added
+ /// multitenancy has none, and requiring it would break resolving this repository at all. No accessor
+ /// means no tenancy, which is the default tenant.
+ ///
+ ///
+ private void EnsureDefaultTenant()
+ {
+ var tenantId = tenantAccessor?.TenantId;
+
+ if (!string.IsNullOrEmpty(tenantId))
+ throw new NotSupportedException(
+ $"The VNext secrets persistence provider does not support multitenancy, and the current tenant is '{tenantId}'. "
+ + "It stores secrets keyed by name alone, so tenants would share them. Use an Entity Framework Core secrets provider.");
+ }
+
public const string StorageUnitName = "Secrets";
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
@@ -17,12 +48,14 @@ public class VNextSecretRepository(IDocumentStore documentStore) : ISecretReposi
public async Task GetAsync(string normalizedName, CancellationToken cancellationToken = default)
{
+ EnsureDefaultTenant();
var document = await documentStore.LoadAsync(StorageUnitName, NormalizeDocumentId(normalizedName), cancellationToken);
return document is null ? null : Deserialize(document);
}
public async Task> ListAsync(CancellationToken cancellationToken = default)
{
+ EnsureDefaultTenant();
var results = new List();
foreach (var status in Enum.GetValues())
{
@@ -38,6 +71,7 @@ public class VNextSecretRepository(IDocumentStore documentStore) : ISecretReposi
public async Task AddAsync(Secret secret, CancellationToken cancellationToken = default)
{
+ EnsureDefaultTenant();
try
{
await SaveAsync(secret, expectedVersion: 0, cancellationToken);
@@ -50,6 +84,7 @@ public class VNextSecretRepository(IDocumentStore documentStore) : ISecretReposi
public async Task TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default)
{
+ EnsureDefaultTenant();
while (true)
{
var existing = await LoadDocumentAsync(secret.Name, cancellationToken);
@@ -69,6 +104,7 @@ public class VNextSecretRepository(IDocumentStore documentStore) : ISecretReposi
public async Task SaveAsync(Secret secret, CancellationToken cancellationToken = default)
{
+ EnsureDefaultTenant();
var existing = await LoadDocumentAsync(secret.Name, cancellationToken);
await SaveAsync(secret, existing?.Document.Version ?? 0, cancellationToken);
}
diff --git a/src/modules/Elsa.Secrets/Models/Secret.cs b/src/modules/Elsa.Secrets/Models/Secret.cs
index f3da9db81..a137439bb 100644
--- a/src/modules/Elsa.Secrets/Models/Secret.cs
+++ b/src/modules/Elsa.Secrets/Models/Secret.cs
@@ -1,8 +1,23 @@
+using Elsa.Common.Entities;
+
namespace Elsa.Secrets.Models;
-public class Secret
+public class Secret : Entity
{
- public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ ///
+ /// Assigns the identifier the property initializer used to provide.
+ ///
+ ///
+ /// is declared null!, and nothing in this module assigns a secret's id --
+ /// there is no identity generator on the create path, so the initializer this replaces was load-bearing.
+ /// Dropping it while deriving would have produced a null id on every insert, which unit tests that build
+ /// a Secret by hand would not have noticed.
+ ///
+ public Secret()
+ {
+ Id = Guid.NewGuid().ToString("N");
+ }
+
public string Name { get; set; } = default!;
public string DisplayName { get; set; } = default!;
public string? Description { get; set; }
diff --git a/test/unit/Elsa.Persistence.VNext.UnitTests/VNextSecretRepositoryTests.cs b/test/unit/Elsa.Persistence.VNext.UnitTests/VNextSecretRepositoryTests.cs
index 4eb99f468..612784521 100644
--- a/test/unit/Elsa.Persistence.VNext.UnitTests/VNextSecretRepositoryTests.cs
+++ b/test/unit/Elsa.Persistence.VNext.UnitTests/VNextSecretRepositoryTests.cs
@@ -1,3 +1,7 @@
+using Microsoft.Extensions.DependencyInjection;
+using Elsa.Secrets.Persistence.VNext.Extensions;
+using Elsa.Secrets.Contracts;
+using Elsa.Persistence.VNext.Document;
using Elsa.Persistence.VNext.Sqlite;
using Elsa.Secrets.Models;
using Elsa.Secrets.Persistence.VNext;
@@ -15,7 +19,7 @@ public class VNextSecretRepositoryTests : IAsyncDisposable
public VNextSecretRepositoryTests()
{
_store = new SqliteDocumentStore(_connection, new SecretPersistenceSchemaProvider().DescribeSchema());
- _repository = new VNextSecretRepository(_store);
+ _repository = new VNextSecretRepository(_store, new StubTenantAccessor(string.Empty));
}
[Fact]
@@ -88,4 +92,44 @@ public class VNextSecretRepositoryTests : IAsyncDisposable
}
};
}
+
+ [Theory]
+ // The provider keys documents by name alone, so serving a tenant would hand back another tenant's secret.
+ // The default-tenant path needs no case of its own: every other test in this class runs through the same
+ // guard with an empty tenant id, which is what a single-tenant deployment does.
+ [InlineData("tenant-a")]
+ [InlineData("*")]
+ public async Task RefusesToServeANonDefaultTenant(string tenantId)
+ {
+ var repository = new VNextSecretRepository(_store, new StubTenantAccessor(tenantId));
+
+ await Assert.ThrowsAsync(() => repository.ListAsync());
+ await Assert.ThrowsAsync(() => repository.GetAsync("anything"));
+ await Assert.ThrowsAsync(() => repository.AddAsync(new Secret { Name = "a", DisplayName = "a" }));
+ }
+
+
+ [Fact]
+ public void ResolvesFromAContainerThatNeverAddedMultitenancy()
+ {
+ // The tenancy guard needs an ITenantAccessor, which the tenants module registers -- so a host that
+ // never added multitenancy has none. Taking it as a required dependency made resolving the repository
+ // throw for exactly the deployments the guard is meant to leave alone. Every other test here
+ // constructs the repository directly and so could not see that; this one goes through the container,
+ // which is the only place the failure existed.
+ var services = new ServiceCollection();
+ services.AddSingleton(_store);
+ services.AddSecretsPersistenceVNext();
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.NotNull(provider.GetRequiredService());
+ }
+
+ private sealed class StubTenantAccessor(string tenantId) : Elsa.Common.Multitenancy.ITenantAccessor
+ {
+ public string TenantId { get; } = tenantId;
+ public Elsa.Common.Multitenancy.Tenant? Tenant => null;
+ public IDisposable PushContext(Elsa.Common.Multitenancy.Tenant? tenant) => throw new NotSupportedException();
+ }
}
diff --git a/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs b/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs
index 526a52b70..ddc54ff25 100644
--- a/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs
+++ b/test/unit/Elsa.Secrets.UnitTests/EFCoreSecretRepositoryTests.cs
@@ -45,6 +45,42 @@ public class EFCoreSecretRepositoryTests : IAsyncLifetime
File.Delete(_databasePath);
}
+ [Fact]
+ public async Task SecretNamesAreUniquePerTenantRatherThanGlobally()
+ {
+ // Asserted against the schema rather than through the repository. The repository's own duplicate-name
+ // check queries dbContext.Secrets, which the global query filter already scopes to the ambient tenant
+ // when multitenancy is on -- so exercising it here would test SetTenantIdFilter, not this change. What
+ // this change owns is the index, and a global unique index would make the name a shared resource: the
+ // second tenant to want "smtp:password" could not create one.
+ await using var scope = _serviceProvider.CreateAsyncScope();
+ var factory = scope.ServiceProvider.GetRequiredService>();
+ await using var dbContext = await factory.CreateDbContextAsync();
+
+ var index = dbContext.Model
+ .FindEntityType(typeof(Secret))!
+ .GetIndexes()
+ .Single(x => x.IsUnique);
+
+ Assert.Equal(["TenantId", SecretShadowPropertyNames.NormalizedName], index.Properties.Select(x => x.Name));
+ }
+
+ [Fact]
+ public async Task ExistingSecretsCarryNoTenantUntilOneIsAssigned()
+ {
+ // 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.
+ await using var scope = _serviceProvider.CreateAsyncScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+
+ await repository.AddAsync(new Secret { Name = "legacy:secret", DisplayName = "Legacy" });
+
+ var stored = await repository.GetAsync("legacy:secret");
+ Assert.NotNull(stored);
+ Assert.Null(stored!.TenantId);
+ }
+
[Fact]
public async Task PersistsSecretAggregate()
{