Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
15fd780
Enable multitenancy support and normalize tenant ID handling.
sfmskywalker Jan 27, 2026
847a427
Add ADR for adopting empty string as the default tenant ID
sfmskywalker Jan 27, 2026
07fe0e7
Apply suggestion from @sfmskywalker
sfmskywalker Jan 28, 2026
e385234
Update doc/adr/graph.dot
sfmskywalker Jan 28, 2026
62c9989
Normalize spacing and improve readability in `Program.cs`. Fix multit…
sfmskywalker Jan 28, 2026
02ac3e5
Merge remote-tracking branch 'origin/bug/tenant-id' into bug/tenant-id
sfmskywalker Jan 28, 2026
0fe0919
Fix ADR numbering and update TOC
sfmskywalker Jan 28, 2026
f027e05
Add ADRs for flowchart execution model, tenant deletion event, merge …
sfmskywalker Jan 28, 2026
1c92763
Add unit tests for tenant ID normalization and multitenancy pipeline …
sfmskywalker Jan 28, 2026
d849aea
Update unit tests for `ActivityConstructionResult`
sfmskywalker Jan 28, 2026
6c1e6fc
Enable configuration-based multitenancy with tenant-specific settings
sfmskywalker Jan 27, 2026
970ca15
Update database indexes to include `TenantId` for multitenancy support
sfmskywalker Jan 27, 2026
4395aff
Add tenant filtering to `DefaultWorkflowDefinitionStorePopulator`
sfmskywalker Jan 28, 2026
42a7c49
Update doc/adr/toc.md
sfmskywalker Jan 28, 2026
dec0f4a
Remove `CommonPersistenceFeature` as it has been deprecated
sfmskywalker Jan 28, 2026
d5e4e59
Merge remote-tracking branch 'origin/bug/tenant-id' into bug/tenant-id
sfmskywalker Jan 28, 2026
9bcd6ca
Add tenant-specific filtering to workflow import logic in `DefaultWor…
sfmskywalker Jan 28, 2026
6833d5e
Replace hardcoded tenant ID with `Tenant.DefaultTenantId` in integrat…
sfmskywalker Jan 28, 2026
d86dc21
Update database indexes and migration logic to support `TenantId` for…
sfmskywalker Jan 28, 2026
7ec5bef
Remove `TenantId` from workflow identity construction in concurrent t…
sfmskywalker Jan 28, 2026
f4a184a
Introduce `SelectiveMockLockProvider` for precise lock mocking in tests
sfmskywalker Jan 28, 2026
0b271d6
Update Elsa.sln
sfmskywalker Jan 28, 2026
ee44768
Normalize tenant ID handling in `DefaultWorkflowDefinitionStorePopula…
sfmskywalker Jan 29, 2026
d693d58
Merge remote-tracking branch 'origin/bug/tenant-id' into bug/tenant-id
sfmskywalker Jan 29, 2026
f612c87
Refactor `TenantResolverResult` to support explicit resolved/unresolv…
sfmskywalker Jan 29, 2026
8b22c06
Normalize tenant ID handling in `DefaultWorkflowDefinitionStorePopula…
sfmskywalker Jan 30, 2026
cc36043
Refactor `DefaultWorkflowDefinitionStorePopulatorTests`: streamline o…
sfmskywalker Jan 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions Elsa.sln
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
doc\adr\0001-record-architecture-decisions.md = doc\adr\0001-record-architecture-decisions.md
doc\adr\0002-fault-propagation-from-child-to-parent-activities.md = doc\adr\0002-fault-propagation-from-child-to-parent-activities.md
doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md = doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md
doc\adr\0004-token-centric-flowchart-execution-model.md = doc\adr\0004-token-centric-flowchart-execution-model.md
doc\adr\graph.dot = doc\adr\graph.dot
doc\adr\toc.md = doc\adr\toc.md
doc\adr\0005-activity-execution-snapshots.md = doc\adr\0004-activity-execution-snapshots.md
doc\adr\0006-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
doc\adr\0005-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
doc\adr\0005-token-centric-flowchart-execution-model.md = doc\adr\0005-token-centric-flowchart-execution-model.md
doc\adr\0006-tenant-deleted-event.md = doc\adr\0006-tenant-deleted-event.md
doc\adr\0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md
doc\adr\0008-empty-string-as-default-tenant-id.md = doc\adr\0008-empty-string-as-default-tenant-id.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"
Expand Down Expand Up @@ -327,6 +329,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core.UnitTe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.UnitTests", "test\unit\Elsa.Common.UnitTests\Elsa.Common.UnitTests.csproj", "{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Tenants.UnitTests", "test\unit\Elsa.Tenants.UnitTests\Elsa.Tenants.UnitTests.csproj", "{DC476900-D836-4920-A696-CF8796668723}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -591,6 +595,10 @@ Global
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC}.Release|Any CPU.Build.0 = Release|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -694,6 +702,7 @@ Global
{874F5A44-DB06-47AB-A18C-2D13942E0147} = {477C2416-312D-46AE-BCD6-8FA1FAB43624}
{B8006D70-1630-43DB-A043-FA89FAC70F37} = {18453B51-25EB-4317-A4B3-B10518252E92}
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92}
{DC476900-D836-4920-A696-CF8796668723} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 4. Token-Centric Flowchart Execution Model
# 5. Token-Centric Flowchart Execution Model

Date: 2025-05-06

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 5. Tenant Deleted Event
# 6. Tenant Deleted Event

Date: 2025-08-05

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 6. Adoption of Explicit Merge Modes for Flowchart Joins
# 7. Adoption of Explicit Merge Modes for Flowchart Joins

Date: 2025-09-30

Expand Down
48 changes: 48 additions & 0 deletions doc/adr/0008-empty-string-as-default-tenant-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 8. Empty String as Default Tenant ID

Date: 2026-01-27

## Status

Accepted

## Context

The multitenancy system in Elsa supports an optional mode where, when multitenancy is disabled, the system assumes a single tenant. When enabled, there's still a default tenant involved. The convention has been to use `null` as the tenant ID for the default tenant.

However, this convention created several issues:

1. **Dictionary compatibility**: The `DefaultTenantResolverPipelineInvoker` attempts to build a dictionary of tenants by their ID using `ToDictionary(x => x.Id)`, which throws an exception because dictionaries do not support null keys.
2. **Inconsistency**: The codebase used `null`, empty string (`""`), and string literal `"default"` interchangeably to refer to the default tenant across different parts of the system (e.g., in configuration files and database records).
3. **Code clarity**: Using `null` as a sentinel value for "default" is implicit and can be unclear to developers reading the code.

## Decision

We will standardize on using an **empty string** (`""`) as the tenant ID for the default tenant instead of `null`. This decision includes:

1. **Define a constant**: Add `Tenant.DefaultTenantId = ""` to explicitly document the convention.
2. **Update Tenant.Default**: Change `Tenant.Default.Id` from `null!` to use the `DefaultTenantId` constant.
3. **Add normalization helper**: Create a `NormalizeTenantId()` extension method that converts `null` to empty string, ensuring backwards compatibility with code that still uses null.
4. **Apply normalization consistently**: Use the normalization method in:
- Dictionary creation in `DefaultTenantResolverPipelineInvoker`
- Tenant lookups in `TenantResolverContext`
- Any other places where tenant IDs are compared or used as dictionary keys

## Consequences

### Positive

- **No more exceptions**: Empty string is a valid dictionary key, eliminating the runtime exception in `DefaultTenantResolverPipelineInvoker`.
- **Backwards compatible**: The `NormalizeTenantId()` extension method ensures that existing code using `null` or empty string will work correctly.
- **Explicit convention**: The `DefaultTenantId` constant makes the convention clear and self-documenting.
- **Simplified logic**: Reduces the need for null-checking throughout the multitenancy code.
- **Consistency**: Aligns with parts of the codebase that were already using empty string (e.g., in configuration files).

### Negative

- **Migration consideration**: Existing data stores that have `null` tenant IDs will need to be normalized to empty strings, though the normalization helper provides a runtime solution.
- **String vs null semantics**: Some developers may find using empty string less intuitive than null for representing "no tenant", though this is mitigated by the explicit constant.

### Neutral

- The empty string convention is common in multitenancy systems and aligns with string-based identifier patterns used elsewhere in the codebase.
8 changes: 7 additions & 1 deletion doc/adr/graph.dot
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ _3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003
_2 -> _3 [style="dotted", weight=1];
_4 [label="4. Activity Execution Snapshots"; URL="0004-activity-execution-snapshots.html"];
_3 -> _4 [style="dotted", weight=1];
_5 [label="5. Tenant Deleted Event"; URL="0005-tenant-deleted-event.html"];
_5 [label="5. Token-Centric Flowchart Execution Model"; URL="0005-token-centric-flowchart-execution-model.html"];
_4 -> _5 [style="dotted", weight=1];
_6 [label="6. Tenant Deleted Event"; URL="0006-tenant-deleted-event.html"];
_5 -> _6 [style="dotted", weight=1];
_7 [label="7. Adoption of Explicit Merge Modes for Flowchart Joins"; URL="0007-adoption-of-explicit-merge-modes-for-flowchart-joins.html"];
_6 -> _7 [style="dotted", weight=1];
_8 [label="8. Empty String as Default Tenant ID"; URL="0008-empty-string-as-default-tenant-id.html"];
_7 -> _8 [style="dotted", weight=1];
}
}
5 changes: 4 additions & 1 deletion doc/adr/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
* [2. Fault Propagation from Child to Parent Activities](0002-fault-propagation-from-child-to-parent-activities.md)
* [3. Direct Bookmark Management in WorkflowExecutionContext](0003-direct-bookmark-management-in-workflowexecutioncontext.md)
* [4. Activity Execution Snapshots](0004-activity-execution-snapshots.md)
* [5. Tenant Deleted Event](0005-tenant-deleted-event.md)
* [5. Token-Centric Flowchart Execution Model](0005-token-centric-flowchart-execution-model.md)
* [6. Tenant Deleted Event](0006-tenant-deleted-event.md)
* [7. Adoption of Explicit Merge Modes for Flowchart Joins](0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md)
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)
15 changes: 14 additions & 1 deletion src/apps/Elsa.Server.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Features.Services;
using Elsa.Identity.Multitenancy;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Persistence.EFCore.Modules.Management;
using Elsa.Persistence.EFCore.Modules.Runtime;
using Elsa.Server.Web.Activities;
using Elsa.Server.Web.ActivityHosts;
using Elsa.Server.Web.Filters;
using Elsa.Tenants;
using Elsa.Tenants.AspNetCore;
using Elsa.Tenants.Extensions;
using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions;
Expand All @@ -29,7 +31,7 @@
// ReSharper disable RedundantAssignment
const bool useReadOnlyMode = false;
const bool useSignalR = false; // Disabled until Elsa Studio sends authenticated requests.
const bool useMultitenancy = false;
const bool useMultitenancy = true;
const bool disableVariableWrappers = false;

ObjectConverter.StrictMode = true;
Expand Down Expand Up @@ -118,6 +120,17 @@
http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options);
http.UseCache();
});

if(useMultitenancy)
{
elsa.UseTenants(tenants =>
{
tenants.UseConfigurationBasedTenantsProvider(options => configuration.GetSection("Multitenancy").Bind(options));
tenants.ConfigureMultitenancy(options => options.TenantResolverPipelineBuilder = new TenantResolverPipelineBuilder()
.Append<CurrentUserTenantResolver>());
});
}

ConfigureForTest?.Invoke(elsa);
});

Expand Down
13 changes: 13 additions & 0 deletions src/apps/Elsa.Server.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
}
}
},
{
"Id": "tenant-2",
"Name": "Tenant 2",
"Configuration": {
"Http": {
"Prefix": "tenant-2",
"Host": "localhost:5001"
},
"ConnectionStrings": {
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
}
}
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ protected virtual TenantResolverResult Resolve(TenantResolverContext context)
/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.
/// </summary>
protected TenantResolverResult Resolved(string tenantId) => new(tenantId);
protected TenantResolverResult Resolved(string? tenantId) => TenantResolverResult.Resolved(tenantId);

/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.
/// </summary>
protected TenantResolverResult Unresolved() => new(null);
protected TenantResolverResult Unresolved() => TenantResolverResult.Unresolved();

/// <summary>
/// Automatically resolves the tenant if the tenant ID is not null.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ public TenantResolverContext(IDictionary<string, Tenant> tenants, CancellationTo
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <returns>The found tenant or null if no tenant with the provided ID exists.</returns>
public Tenant? FindTenant(string tenantId)
public Tenant? FindTenant(string? tenantId)
{
return _tenantsDictionary.TryGetValue(tenantId, out var tenant) ? tenant : null;
var normalizedId = tenantId.NormalizeTenantId();
return _tenantsDictionary.TryGetValue(normalizedId, out var tenant) ? tenant : null;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Elsa.Common.Multitenancy;

/// <summary>
/// A strategy for resolving the current tenant. This is called the tenant initializer.
/// A strategy for resolving the current tenant, called from the tenant initializer.
/// </summary>
public interface ITenantResolver
{
Expand Down
9 changes: 7 additions & 2 deletions src/modules/Elsa.Common/Multitenancy/Entities/Tenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ namespace Elsa.Common.Multitenancy;
[UsedImplicitly]
public class Tenant : Entity
{
/// <summary>
/// The ID used for the default tenant.
/// </summary>
public const string DefaultTenantId = "";

/// <summary>
/// Gets or sets the name.
/// </summary>
Expand All @@ -19,10 +24,10 @@ public class Tenant : Entity
/// Gets or sets the configuration.
/// </summary>
public IConfiguration Configuration { get; set; } = new ConfigurationBuilder().Build();

public static readonly Tenant Default = new()
{
Id = null!,
Id = DefaultTenantId,
Name = "Default"
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ namespace Elsa.Common.Multitenancy;
[UsedImplicitly]
public static class TenantsProviderExtensions
{
/// <summary>
/// Normalizes a tenant ID by converting null to empty string, ensuring consistency with the default tenant convention.
/// </summary>
public static string NormalizeTenantId(this string? tenantId) => tenantId ?? Tenant.DefaultTenantId;

public static async Task<Tenant?> FindByIdAsync(this ITenantsProvider tenantsProvider, string id, CancellationToken cancellationToken = default)
{
var filter = new TenantFilter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,38 @@ namespace Elsa.Common.Multitenancy;
/// <summary>
/// Represents the result of a tenant resolution.
/// </summary>
/// <param name="TenantId">The resolved tenant.</param>
public record TenantResolverResult(string? TenantId)
public record TenantResolverResult
{
private readonly bool _isResolved;

private TenantResolverResult(string? tenantId, bool isResolved)
{
TenantId = tenantId;
_isResolved = isResolved;
}

/// <summary>
/// The normalized tenant ID. Returns null if unresolved.
/// </summary>
public string? TenantId => _isResolved ? field.NormalizeTenantId() : null;

/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.
/// </summary>
/// <param name="tenantId">The resolved tenant.</param>
/// <returns>A new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.</returns>
public static TenantResolverResult Resolved(string tenantId) => new(tenantId);
public static TenantResolverResult Resolved(string? tenantId) => new(tenantId, true);

/// <summary>
/// Creates a new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.
/// </summary>
/// <returns>A new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.</returns>
public static TenantResolverResult Unresolved() => new(default(string?));
public static TenantResolverResult Unresolved() => new(null, false);

/// <summary>
/// Gets a value indicating whether the tenant has been resolved.
/// </summary>
public bool IsResolved => TenantId != null;
public bool IsResolved => _isResolved;

public string ResolveTenantId() => TenantId ?? throw new InvalidOperationException("Tenant has not been resolved.");
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Persistence.EFCore.EntityHandlers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -60,6 +61,9 @@ public override void Apply()
{
options.RunMigrations[typeof(TDbContext)] = RunMigrations;
});

Services.AddScoped<IEntitySavingHandler, ApplyTenantId>();
Services.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>();
}

protected virtual void ConfigureMigrations()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,18 @@ protected override void Up(MigrationBuilder migrationBuilder)
.OldAnnotation("MySql:CharSet", "utf8mb4");

migrationBuilder.CreateIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers",
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
unique: true);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
schema: _schema.Schema,
table: "Triggers");

Expand Down
Loading
Loading