feat(secrets)!: scope secrets to tenants (#7991)

* feat(secrets)!: scope secrets to tenants

Secret was the one user-facing entity with no notion of tenancy. It did not
derive from 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, because secrets:view is
evaluated against the caller rather than against which tenant owns the
secret, so any caller holding it reached the whole set.

Secret now derives from Entity and is filtered like everything else. The
infrastructure was already in place -- SecretsElsaDbContext derives from
ElsaDbContextBase and the feature from PersistenceFeatureBase, which
registers SetTenantIdFilter -- and the handler was skipping secrets for one
reason: it only applies to Entity.

No backfill, deliberately. The column is added nullable and existing rows
keep a null tenant, because SetTenantIdFilter already treats null as the
default tenant through a clause written for exactly this case. Single-tenant
deployments see no change at all, since the filter is only installed when
multitenancy is enabled. Multi-tenant deployments find pre-existing secrets
invisible until assigned, which is a visible failure rather than continued
cross-tenant exposure.

Two things this needed that were not obvious:

Secret self-initialized its Id and nothing else ever assigned one -- there is
no identity generator on the create path -- while Entity.Id is null!. Simply
deriving would have produced a null id on every insert, which any test that
builds a Secret by hand would have missed. A constructor preserves it.

The unique index moves from NormalizedName to (TenantId, NormalizedName),
matching User, Role and Application in the same release. Leaving it global
would have made secret names a shared resource: the second tenant to want
"smtp-password" could not create one.

Elsa.Secrets.Persistence.VNext cannot support this. It keys documents by name
alone and Elsa.Persistence.VNext has no tenant concept to filter on, so it now
throws outside the default tenant rather than serving one tenant's secret to
another. Making it tenant-aware means changing the document id scheme, which
relocates existing documents and is a storage change to make deliberately.

Refs #7972

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(secrets): let the VNext repository resolve without multitenancy

The tenancy guard took ITenantAccessor as a required dependency. That
interface is registered by the tenants module, so a host that never added
multitenancy has none, and resolving ISecretRepository threw for exactly the
deployments the guard is meant to leave alone.

The accessor is now optional, and its absence means no tenancy, which is the
default tenant.

Found by review, and it is worth naming why the tests missed it: every case
in VNextSecretRepositoryTests constructs the repository directly with a stub
accessor, so none of them ever went through the container where the failure
lived. The new case resolves through a service collection that adds only the
document store and the module's own registration, which is what a
single-tenant host looks like. Reverting the fix makes it fail with the same
missing-service exception review reported.

Refs #7972

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-08-26 01:56:55 +02:00 committed by GitHub
parent 6f57a3b623
commit ee40689ef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1080 additions and 30 deletions

View file

@ -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.

View file

@ -0,0 +1,113 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("varchar(255)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("SerializedTags")
.IsRequired()
.HasColumnType("longtext")
.HasColumnName("Tags");
b.Property<string>("SerializedVersions")
.IsRequired()
.HasColumnType("longtext")
.HasColumnName("Versions");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<string>("StoreName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("TenantId")
.HasColumnType("varchar(255)");
b.Property<string>("TypeName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTimeOffset?>("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
}
}
}

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Secrets.Persistence.EFCore.MySql.Migrations.Secrets
{
/// <inheritdoc />
public partial class SecretTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Secret_NormalizedName",
schema: "Elsa",
table: "Secrets");
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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);
}
}
}

View file

@ -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<string>("TenantId")
.HasColumnType("varchar(255)");
b.Property<string>("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

View file

@ -0,0 +1,114 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("NVARCHAR2(450)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TIMESTAMP(7) WITH TIME ZONE");
b.Property<string>("Description")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("SerializedTags")
.IsRequired()
.HasColumnType("NCLOB")
.HasColumnName("Tags");
b.Property<string>("SerializedVersions")
.IsRequired()
.HasColumnType("NCLOB")
.HasColumnName("Versions");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("NVARCHAR2(32)");
b.Property<string>("StoreName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
b.Property<string>("TenantId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("TypeName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
b.Property<DateTimeOffset?>("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
}
}
}

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Secrets.Persistence.EFCore.Oracle.Migrations.Secrets
{
/// <inheritdoc />
public partial class SecretTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Secret_NormalizedName",
schema: "Elsa",
table: "Secrets");
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
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);
}
}
}

View file

@ -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<string>("TenantId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("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

View file

@ -0,0 +1,113 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SerializedTags")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Tags");
b.Property<string>("SerializedVersions")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Versions");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("StoreName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TenantId")
.HasColumnType("text");
b.Property<string>("TypeName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("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
}
}
}

View file

@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Secrets.Persistence.EFCore.PostgreSql.Migrations.Secrets
{
/// <inheritdoc />
public partial class SecretTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Secret_NormalizedName",
schema: "Elsa",
table: "Secrets");
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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);
}
}
}

View file

@ -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<string>("TenantId")
.HasColumnType("text");
b.Property<string>("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

View file

@ -0,0 +1,114 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SerializedTags")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasColumnName("Tags");
b.Property<string>("SerializedVersions")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasColumnName("Versions");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("StoreName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<string>("TypeName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("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
}
}
}

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Secrets.Persistence.EFCore.SqlServer.Migrations.Secrets
{
/// <inheritdoc />
public partial class SecretTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Secret_NormalizedName",
schema: "Elsa",
table: "Secrets");
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
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);
}
}
}

View file

@ -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<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<string>("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

View file

@ -0,0 +1,109 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Scope")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SerializedTags")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("Tags");
b.Property<string>("SerializedVersions")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("Versions");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("StoreName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("TenantId")
.HasColumnType("TEXT");
b.Property<string>("TypeName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}

View file

@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Secrets.Persistence.EFCore.Sqlite.Migrations.Secrets
{
/// <inheritdoc />
public partial class SecretTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Secret_NormalizedName",
schema: "Elsa",
table: "Secrets");
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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);
}
}
}

View file

@ -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<string>("TenantId")
.HasColumnType("TEXT");
b.Property<string>("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

View file

@ -21,7 +21,10 @@ internal class SecretConfiguration : IEntityTypeConfiguration<Secret>
builder.Property(x => x.StoreName).HasMaxLength(100).IsRequired();
builder.Property(x => x.Scope).HasMaxLength(200);
builder.Property(x => x.Status).HasConversion<string>().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)}");

View file

@ -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
{
/// <summary>
/// Refuses to serve a request made in a non-default tenant context.
/// </summary>
/// <remarks>
/// 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: <c>Elsa.Persistence.VNext</c> 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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<Secret?> 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<IReadOnlyCollection<Secret>> ListAsync(CancellationToken cancellationToken = default)
{
EnsureDefaultTenant();
var results = new List<Secret>();
foreach (var status in Enum.GetValues<SecretStatus>())
{
@ -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<bool> 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);
}

View file

@ -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");
/// <summary>
/// Assigns the identifier the property initializer used to provide.
/// </summary>
/// <remarks>
/// <see cref="Entity.Id"/> is declared <c>null!</c>, 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.
/// </remarks>
public Secret()
{
Id = Guid.NewGuid().ToString("N");
}
public string Name { get; set; } = default!;
public string DisplayName { get; set; } = default!;
public string? Description { get; set; }

View file

@ -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<NotSupportedException>(() => repository.ListAsync());
await Assert.ThrowsAsync<NotSupportedException>(() => repository.GetAsync("anything"));
await Assert.ThrowsAsync<NotSupportedException>(() => 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<IDocumentStore>(_store);
services.AddSecretsPersistenceVNext();
using var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetRequiredService<ISecretRepository>());
}
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();
}
}

View file

@ -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<IDbContextFactory<SecretsElsaDbContext>>();
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<EFCoreSecretRepository>();
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()
{