Repository navigation
fix(identity): tenant-safe role stores for Dapper and Mongo (elsa-core#8615) - #281
sfmskywalker wants to merge 5 commits into
Conversation
Map Dapper RoleFilter.Ids to the Id column, refuse upserts that would re-home another tenant's role, and replace Mongo's store-wide unique index on Role.Name with a compound unique (TenantId, Name) index. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Ordinal sort puts _id_ after Name_1, so ordered-array equality failed on a correct 3.9.0-shaped index list. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
V3_10 (30005) creates IX_Roles_TenantId_Name via FluentMigrator for every provider. Existing same-tenant duplicates (including case variants and NULL/'' default-tenant pairs) fail the migration with named ids; no rows are rewritten. Also dispose Mongo test clients and filter index names with Where to address CodeQL comments on #281. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
sfmskywalker
left a comment
There was a problem hiding this comment.
Elsa 3 Code Review: REQUEST_CHANGES + MEDIUM-HIGH @ a592352
Code Review, Round 1/4
The Dapper half, including the new V3_10 migration, is correct. The Mongo index migration still drops the old index before creating the new one, so several nodes starting together can fail host startup. That is the only blocker.
Blocker
- The Mongo role index migration is unchanged at this head and is not safe when several nodes start at the same time.
CreateIndices.cs:100-110lists the indexes, then dropsName_1at line 104. Two nodes starting together can both seeName_1. The secondDropOneAsyncthen fails withIndexNotFound(code 27).IndexHelpers.CreateAsyncdoes not catch it, and this runs insideIHostedService.StartAsync(CreateIndices.cs:28-35), so that node fails to start. Before this PR the role step only created identical indexes, which is idempotent across nodes. Between the drop at line 104 and the create at line 119, the collection also has no name uniqueness. Fix: createTenantId_1_Name_1first (the two indexes can coexist, and existing data always satisfies the compound because the old index was stricter), then dropName_1, and treat aMongoCommandExceptionwith code 27 as already dropped.
V3_10 (Dapper unique index on Roles (TenantId, Name))
- Ordering:
[Migration(30005, "Elsa:Identity:V3.10")]follows Identity 30001 to 30004 and matches the naming of the existing Identity and Runtime migrations. - Index creation: FluentMigrator 7.2 emits a plain
CREATE UNIQUE INDEXon(TenantId, Name)for SQL Server 2008 and 2016, PostgreSQL, SQLite and MySQL. I checked each generator's output. The existing PostgreSQL Testcontainers suites run the whole migration assembly on a fresh database, so CI runs V3_10 on PostgreSQL. No test runs it on SQL Server. - NULL tenant ids:
- SQL Server treats NULLs as equal in a unique index, so it allows one
(NULL, Name)row per name. That is the right outcome for legacy default-tenant rows, and the pre-check guarantees existing data passes. - SQLite, PostgreSQL and MySQL treat NULLs as distinct, so repeated
(NULL, 'admin')rows are still allowed. I confirmed this on SQLite. - Dapper writes NULL when no tenant context is set and
''under the default tenant context (Store.cs:552-559). The seeder runs in a tenant context, so its rows are covered. - The remark at
V3_10.cs:18-20says "most providers treat NULLs as distinct". Naming SQL Server as the exception would save a maintainer a lookup.
- SQL Server treats NULLs as equal in a unique index, so it allows one
- Case sensitivity: the index follows the database collation. SQL Server's default case-insensitive collation rejects case variants, matching core's
OrdinalIgnoreCasecheck. SQLite and PostgreSQL compare case-sensitively, so the index enforces only exact names there, and a case-variant race still relies on core's pre-save check. Acceptable, but the remarks should say it. - The pre-check is stricter than the index on purpose. It groups by normalized tenant and
ToLowerInvariantname (V3_10.cs:57-88), so case variants and NULL/''pairs fail the migration even where the index would accept them. That matches core's notion of the same role. The cost: such data blocks startup until an operator fixes it. The error message is actionable. It names the index, the tenant, the names and the ids, and says no rows were changed. UsingStringComparer.OrdinalIgnoreCasesemantics (for example upper-invariant keys) would match core exactly instead of almost exactly. - Pre-check SQL:
RolesSelectSql(V3_10.cs:108-116) picks quoting by sniffing the connection type name. It works with the raw provider connections FluentMigrator creates, and PostgreSQL is exercised in CI. It is the one clever part of the file, though, and it departs from the repo's documentedIfDatabaseplusMigrationDatabasespattern (MigrationDatabases.cs:3-13). TwoIfDatabase(...)branches would be clearer. - Transaction: FluentMigrator wraps each migration in a transaction by default, and the read uses it (
V3_10.cs:94). A failed pre-check rolls back and does not record version 30005, whichVersionApplied(30005)asserts. Down()(V3_10.cs:51-55) drops the index only if it exists. Correct.- Readability: the file is mostly the error message builder. Apart from the type-name sniffing, a maintainer can follow it.
- Runtime: an index violation surfaces as a raw provider exception. That gives a 500 from
POST /identity/roles, and the seeder logs a background-task error. The second node in a concurrent seeding race now fails instead of creating a duplicate admin role.
Verified (unchanged from the previous head)
RoleFilter.Idsmaps to theIdcolumn (Dapper/.../Stores/RoleStore.cs:71). Legacy rows where id equals name still resolve.- The
SaveAsynctenant guard (RoleStore.cs:18-38) updates only owned rows, inserts unused ids and refuses foreign ids with an "already exists"InvalidOperationException(409 on core's create endpoint). It cannot re-home another tenant's row,*rows are refused, and NULL legacy rows belong to the default tenant. It adds no dialect-specific SQL. RoleFilter.Namedeferral is safe: the pinned 3.10.0-preview.5760 has noRoleFilter.Name, and after the bump core'sFindByNameAsyncre-matches in memory. The bump PR should still add theNameclause, so that a future name-onlyFindAsyncorDeleteAsynccannot match every tenant role.
Non-blocking
- Put the tenant in the Dapper
UPDATEWHEREclause through the filteredUpdateAsyncoverload (Store.cs:451) for defense in depth. - The refusal message says "in another tenant" even when the owner is a
*row. - Mongo index detection is by name only (
CreateIndices.cs:102,113). The "was not found" and "already present" lines log at Information on every startup, so Debug fits better. The newWhererewrite (CreateIndices.cs:143-145) looks the name up twice and reads worse than the loop it replaced. The bot suggestion was optional. - Tests:
- The private
TestTenantAccessornow appears in two Dapper test files and one Mongo test file. A shared helper would remove the copies. Path.Combine(GetTempPath(), Path.GetFileName(...))reads oddly.Path.Joinstates the intent.- There is still no test for a default-tenant update of a NULL legacy row, or for a tenant saving over a
*role's id.
- The private
- Release-note items:
- With the
Idsfix, Dapper users whose roles hold a legacy id that differs from the name start receiving that role's permissions. - V3_10 fails startup on same-tenant duplicate role names, case variants included, until the rows are fixed.
- With the
Residual risk (out of scope, not blocking)
- The Dapper role index is now covered. Concurrent seeding can no longer create two
adminroles in one tenant, except for NULL-tenant rows on SQLite, PostgreSQL and MySQL, and case variants on case-sensitive databases. - Dapper
Usersstill has no name index, so two nodes seeding the same tenant at once can still create twoadminusers. - Mongo keeps store-wide unique indexes on
User.Name,Application.NameandApplication.ClientId, so in a shared store tenant B'sadminuser still fails with a duplicate key.
Tests and CI
- Locally I ran the five
DapperRoleNameUniquenessMigrationTestsand the fiveDapperRoleStoreTestson SQLite, all passing. - This machine has no Docker, so the PostgreSQL and Mongo Testcontainers suites ran only in CI.
- CI is green at this head:
- ubuntu-latest:
Elsa.Persistence.Dapper.UnitTests74/74 andElsa.MongoDb.UnitTests51/51. - CodeQL with both Analyze (csharp) runs, Analyze (actions), submit-nuget, GitGuardian and license/cla.
- ubuntu-latest:
- No Greptile or CodeRabbit review has been posted. The gate is waived for this repo. Of the five github-code-quality threads, two are resolved and three are outdated but still open.
Create TenantId_1_Name_1 first so concurrent node startup never leaves Roles without uniqueness, then drop Name_1 and treat IndexNotFound (27) as already dropped. Also name the SQL Server NULL-unique exception in V3_10 remarks and use Path.Join for Dapper test temp files. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Create TenantId_1_Name_1 before dropping Name_1 and treat IndexNotFound as already dropped. Dapper owned updates now go through the tenant-filtered UpdateAsync overload, and the refusal message distinguishes a shared '*' row from another tenant. Mongo index chatter is Debug except the legacy drop. V3_10 quotes via IfDatabase and names the SQL Server NULL exception. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
|
Addressed CR R1 ( B1. Non-blocking. Owned updates use the filtered The Mongo helper stays in |
|
Superseded by elsa-workflows/elsa-core#8616 (merged as 35f577a5), which ports this change, including CR's create-first-then-drop Mongo index fix, into core's src/extensions for 3.10. ext main doesn't publish 3.10, and Sipke decided against a 3.9.x backport, so this PR isn't needed. Thanks for the work here; it was the source for the port. |
Fixes #282
Follows elsa-core#8615 and the core design in elsa-core#8616 (not merged yet). Role ids used to be derived from the role name and were unique across the whole store, so tenants sharing one store could not have same-named roles. Core now generates role ids and looks up roles by name within the tenant. This PR makes the extension role stores match that.
The Dapper
Ids→Namemapping is not only a multi-tenant bug. Once core generates ids, a single-tenant install also fails: a newly seeded admin role has a generated id,User.Rolesstill stores that id, andFindByIdsAsync/RoleFilter.Idswould look the id up in the Name column and miss. MappingIdstoIdis required before ext takes a core preview that includes #8616, including for hosts with one tenant.Release note. Dapper users whose
User.Roles/Application.Roleshold a legacy id that differs from the role name (for examplepower-user) now receive that role's permissions on upgrade. Before this PR those lookups searched the Name column and missed.Scope is the Dapper and Mongo role stores and their tests only. No data migration: existing name-derived ids (
id == name) keep resolving viaRoleFilter.Id/Ids.Dapper (
DapperRoleStore)RoleFilter.Idsnow maps to theIdcolumn. It previously mapped toName(RoleStore.csfilter), which only worked because id equalled name. Generated ids would not have resolved forUser.Roles/Application.Roles(FindByIdsAsync), on single-tenant and shared-store hosts alike.Store.SaveAsyncupserts onIdalone (SQLiteINSERT OR REPLACE, SQL ServerMERGE). That re-homed another tenant's row when ids collided.DapperRoleStore.SaveAsyncnow updates only a row the ambient tenant already owns via the filteredUpdateAsyncoverload (Store.csapplies the tenantWHEREplusId), inserts when the id is unused, and refuses when the id belongs to a different tenant or a shared*row (message distinguishes those cases). Existing name-derived rows are not rewritten.Store<T>(SetTenantIdoverwriting*and explicit tenants, key-only upserts on every entity, no*read visibility). Those remain a separate Store-level fix.RoleFilter.Nameis deferred. This repo pinsElsaVersion3.10.0-preview.5760. ThatRoleFilterhasId,Ids, andTenantIdonly — noNameyet. The Ids→Id fix, the tenant guard, the Mongo index, and the Dapper unique index do not need it. A small bump PR will add.Is(nameof(RoleRecord.Name), filter.Name)once #8616 is published to feedz.Dapper unique index (
Identity:V3.10/IX_Roles_TenantId_Name)3.9
Roleshad no name uniqueness (PK isIdonly;V3_3added nullableTenantId). Two tenants must be able to shareadmin; one tenant must not.Mechanism. New FluentMigrator migration
V3_10(version30005, descriptionElsa:Identity:V3.10) inElsa.Persistence.Dapper.Migrations.Identity, same assembly and style asInitial/V3_1/V3_2/V3_3.Create.Index(...).OnColumn("TenantId").OnColumn("Name").WithOptions().Unique()is provider-neutral, so SQLite, SQL Server, PostgreSQL, MySQL, and Oracle all getIX_Roles_TenantId_Name. Existence guards no-op ifRoles,TenantId, or the index is already there.Downdrops the index.Existing 3.9 data — fail closed, never rewrite. FluentMigrator has no first-class "skip this step, log, and still record the version" that would leave later hosts without uniqueness. Logging-and-skipping while applying
30005would hide collisions and skip the index forever. So the migration:Roles (Id, TenantId, Name)using the repo'sIfDatabase+MigrationDatabasespattern (QuotedIdentifiersfor PostgreSQL/Oracle,UnquotedIdentifiersfor SQLite/SQL Server/MySQL).NULLand''→ default) andName.ToLowerInvariant().InvalidOperationExceptionlisting tenant, stored name spellings, and ids, and says "No rows were changed."30005(theCreate.Indexnever runs). The operator renames or deletes the extras and re-runs.No rows are deleted, renamed, or tenant-rewritten.
Case.
RoleManagercompares names withOrdinalIgnoreCaseand does not trim. Duplicate detection matches that for case (invariant lower) and does not treat"admin"/"admin "as the same. The unique index itself is on the stored columns and follows the database collation: typically case-insensitive on SQL Server (matchesRoleManager), case-sensitive on SQLite / PostgreSQL / MySQL / Oracle. Case variants on SQLite and PostgreSQL therefore rely on core'sOrdinalIgnoreCasepre-save check; the index only rejects exact stored names there. Detection refuses 3.9 case-variants at upgrade time so they cannot land under a case-sensitive index.NULLvs''tenant ids.Store.SetTenantIdwritestenant?.Id(null for the default tenant).ApplyTenantFiltertreatsNULLand''as the same default tenant (IsNullOrEmpty). Duplicate detection matches that (both key as default), so a mixedNULL/''pair with the same name fails the upgrade. The unique index does not:(NULL, Name)per name is allowed (right outcome for legacy default-tenant rows).(NULL, 'admin')is allowed, and(NULL, 'admin')plus('', 'admin')can both exist.Closing that gap needs
NULL/''normalisation (elsa-extensions#242 / #245), not a data rewrite here.Mongo (
MongoRoleStore/CreateIndices)MongoRoleStorestill usesfilter.Apply. It will pick upRoleFilter.Namefor free once ext builds against a core that has #8616. No filter change in this PR.MongoDbStore. Covered again throughMongoRoleStoretests.Role.Nameand create a compound unique index on(TenantId, Name).Name_1(unique onName),TenantId_1,_id_TenantId_1_Name_1(unique onTenantId+Name),TenantId_1,_id_TenantId_1_Name_1first if missing (the two unique indexes can coexist; existingName_1data always satisfies the compound). Then dropName_1. A second node whoseDropOneraces getsIndexNotFound(code 27) and logs "was not found" at Debug instead of failingIHostedService.StartAsync. That keeps name uniqueness on the collection the whole time and makes the drop idempotent across nodes.TenantId_1_Name_1only if it is missing; dropName_1by that exact name and treat code 27 as already dropped. Logs: created / already present / not found at Debug; the single "Dropped … Name_1" line at Information.User.Name(Name_1unique) andApplication.Name/Application.ClientId(Name_1/ClientId_1unique).Proved against a 3.9.0-shaped database that already had roles in two tenants and the old single-field index: create-then-drop succeeds on that data, two tenants can then hold a same-named role, a same-tenant duplicate is rejected, a second run is a no-op, and a start after
Name_1was already dropped (or after the compound was already created) does not throw.Tests
Dapper (SQLite) and Mongo (Testcontainers
mongo:7.0.24) cover:id == namestill resolves viaId/IdsIds(Dapper also assertsIdsno longer matchesName)*rowName_1, compound-already-created, and User/Application indexes left intactV3_10on a 3.9-shaped SQLite database that already has two tenants with a same-named role plus legacyid == namerows: index is created, rows are unchanged, and those ids still resolveNULL/''default-tenant pairs fail the migration, leave rows unchanged, and do not record version30005V3_10, a same-tenant duplicate insert is rejected both throughDapperRoleStore.SaveAsyncand raw SQLAnalysis (not in this PR — candidate 3.10 follow-up)
Mongo still has store-wide unique indexes on:
User.NameName_1uniqueadmin. Same class of bug as roles, for the default admin user.Application.NameName_1uniqueApplication.ClientIdClientId_1uniqueClientIdcannot apply a tenant filter. A compound(TenantId, ClientId)unique index would allow two tenants to reuse a client id, and the first match (or a default-tenant miss) would be wrong. TreatClientIdas store-global unless the token/API-key pipeline is taught to resolve tenant first.Dapper
Rolesnow hasIX_Roles_TenantId_Name. DapperUsersandApplicationsstill have no unique indexes onNameorClientId— only theIdprimary key (Initial+V3_3TenantId column). Dapper therefore does not reject same-named users or applications at the database. Lookups still go through the store's tenant filter (UserFilter.Name,ApplicationFilter.Name/ClientId), so same-tenant duplicates are possible and a pre-tenantClientIdlookup would see only the ambient/default tenant's row. That is a separate 3.10 issue, not this PR.