* 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>
103 lines
6.3 KiB
Markdown
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.
|