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

103 lines
6.3 KiB
Markdown

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