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