elsa-core/doc/migrations/secrets-tenancy.md
Sipke Schoorstra 7e8e7aa012
docs(secrets): document null-tenant index gaps and the MySql TFM pin (#7998)
* docs(secrets): document null-tenant index gaps and the MySql TFM pin

The per-tenant unique indexes only backstop rows whose TenantId is non-null,
so single-tenant deployments and pre-upgrade rows fall back to the pre-save
existence checks for name uniqueness. Recorded in secrets-tenancy.md and the
authorization-model guide, with a comment at the EFCore secret repository's
write path.

Also documents why Elsa.Secrets.Persistence.EFCore.MySql stays pinned to
net8.0/net9.0: Pomelo.EntityFrameworkCore.MySql tops out at EF Core 9, and
the project references Elsa.Persistence.EFCore.MySql which carries the same
pin. Comment-only csproj change; no behavior changes anywhere in this commit.

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

* docs(secrets): say plainly that the null-tenant backfill is not a fix

The guide implied backfilling TenantId to "" restored the uniqueness
guarantee in single-tenant mode. It does not: disabled-mode writes keep
persisting null, so new rows still land outside the index and two
concurrent creates can still commit the same name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:45:49 +02:00

6.3 KiB

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.

Null-tenant rows sit outside the index

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

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.

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

The MySQL provider ships for net8.0 and net9.0 only

Elsa.Secrets.Persistence.EFCore.MySql targets net8.0;net9.0, while the Sqlite, SQL Server, PostgreSQL and Oracle secrets providers also target net10.0. That is a dependency constraint, not an oversight: Pomelo.EntityFrameworkCore.MySql tops out at 9.0.0, built for EF Core 9, so there is no net10.0 provider to build against. Every MySQL project in the repository carries the same pin, and the secrets one additionally inherits it by referencing Elsa.Persistence.EFCore.MySql.

A net10.0 host referencing the MySQL secrets provider resolves the net9.0 asset and runs normally, including the SecretTenancy migration — migrations are ordinary C# and do not depend on the host framework. The pins come out together once Pomelo ships for EF Core 10.

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.