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() {