Implement null TenantId for tenant-agnostic entities#7226
Implement null TenantId for tenant-agnostic entities#7226sfmskywalker merged 7 commits intorelease/3.6.0from
Conversation
Introduce ADR-0009 to document the use of `null` for tenant-agnostic entities, enhancing multitenancy handling. Update multitenancy features across the codebase, including EF Core query filters and ActivityRegistry, to handle null as a tenant ID, ensuring tenant-agnostic entities are accessible across all tenants.
There was a problem hiding this comment.
Pull request overview
Adds tenant-aware behavior to testing infrastructure and core registries so tests (and runtime components) can operate in a tenant-agnostic way, while also introducing an ADR describing null tenant IDs as “tenant-agnostic”.
Changes:
- Registers
ITenantAccessorinActivityTestFixtureand sets a default tenant context for component tests. - Introduces tenant-aware
ActivityDescriptor/ActivityRegistrybehavior and updates workflow-definition activity descriptors to carry aTenantId. - Updates EF Core tenant query filter behavior to include
TenantId == nullentities and adds ADR-0009 documenting thenulltenant ID convention.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowActivities/Workflows/DeleteWorkflow.cs | Removes hard-coded tenant assignment for test workflow definition. |
| test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowActivities/DeleteWorkflowTests.cs | Adds verification that the deleted workflow definition no longer exists in the store. |
| test/component/Elsa.Workflows.ComponentTests/Helpers/Services/ComponentTestTenantResolver.cs | Adds a component-test tenant resolver that resolves to the default tenant. |
| test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs | Configures multitenancy/tenant resolver pipeline for component test server. |
| test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs | Pushes a default tenant context into the test scope. |
| src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs | Removes tenant-prefixed IDs and tenant accessor dependency when materializing CLR workflows. |
| src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs | Stops tenant-prefixing type names and instead stores tenant ID on the descriptor. |
| src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs | Introduces tenant-aware indexing/filtering for activity descriptors. |
| src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs | Adds nullable TenantId to represent tenant-agnostic descriptors. |
| src/modules/Elsa.Persistence.EFCore.Common/EntityHandlers/SetTenantIdFilter.cs | Expands EF Core query filter to include TenantId == null entities. |
| src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantAccessor.cs | Adds TenantId convenience property to the tenant accessor. |
| src/modules/Elsa.Common/Multitenancy/Contracts/ITenantAccessor.cs | Adds TenantId to the tenant accessor contract. |
| src/common/Elsa.Testing.Shared/ActivityTestFixture.cs | Registers ITenantAccessor for activity unit testing fixture. |
| src/apps/Elsa.Server.Web/Program.cs | Enables multitenancy in the sample server app by default. |
| doc/adr/toc.md | Adds ADR-0009 to ADR table of contents. |
| doc/adr/graph.dot | Adds ADR-0009 node to ADR graph. |
| doc/adr/0009-null-tenant-id-for-tenant-agnostic-entities.md | Documents the new null tenant ID convention for tenant-agnostic entities. |
| Elsa.sln | Adds ADR-0009 to the solution items. |
src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantAccessor.cs
Outdated
Show resolved
Hide resolved
src/modules/Elsa.Persistence.EFCore.Common/EntityHandlers/SetTenantIdFilter.cs
Outdated
Show resolved
Hide resolved
...agement/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs
Show resolved
Hide resolved
…nantAccessor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@sfmskywalker I've opened a new pull request, #7227, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@sfmskywalker I've opened a new pull request, #7228, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@sfmskywalker I've opened a new pull request, #7229, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@sfmskywalker I've opened a new pull request, #7230, to work on those changes. Once the pull request is ready, I'll request review from you. |
…onActivityDescriptorFactory (#7230) * Initial plan * Remove unused using Elsa.Common.Multitenancy statement Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
…without performance regression (#7227) * Initial plan * Optimize Find(string type) to prefer tenant-specific descriptors with single-pass iteration Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Apply review feedback: combine if statements and add comprehensive unit tests Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Refactor tests for DRYness using theories and helper methods Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Clean up extra whitespace in test file Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
|
@sfmskywalker I've opened a new pull request, #7231, to work on those changes. Once the pull request is ready, I'll request review from you. |
…rsion (#7229) * Initial plan * Remove NullIfEmpty conversion to align with ADR-0008 and ADR-0009 - Updated ElsaDbContextBase to use empty string for default tenant - Updated ApplyTenantId to stop converting empty string to null - Updated TenantAwareDbContextFactory to preserve empty string for default tenant - Updated Store.cs to preserve empty string for default tenant - This ensures: null = tenant-agnostic (visible to all), "" = default tenant Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Add database migration to convert null TenantId to empty string for SqlServer - Added Management migration to convert null to "" for WorkflowDefinitions and WorkflowInstances - Added Runtime migration to convert null to "" for all runtime entities - This ensures existing default tenant data is properly migrated per ADR-0008 - Note: Similar migrations needed for PostgreSql, MySql, Sqlite, and Oracle providers Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Clarify tenant handling logic in `ElsaDbContextBase` with new ADR references. * Add tenant ID convention analysis documents and quick reference guide * Implement tenant-agnostic functionality across modules - Introduce `AgnosticTenantId` constant to manage tenant-agnostic entities. - Modify entity handling logic to respect tenant-agnostic designations. - Adjust workflow processing to include tenant-agnostic workflows. - Update caching and activity descriptor logic to accommodate the `AgnosticTenantId`. * Refactor tenant management and registry logic in `ActivityRegistry` for improved clarity and separation of tenant-specific and tenant-agnostic activity descriptors. Remove `TestTenantResolver` and update workflow definition handling for tenant support. * Refactor `ActivityRegistry`: prioritize tenant-specific descriptors over tenant-agnostic and simplify descriptor retrieval logic. * Improve async handling in `CommandHandlerInvokerMiddleware` to await tasks without blocking * Update ADR to use asterisk as sentinel value for tenant-agnostic entities Replace the previous convention of using `null` for tenant-agnostic entities with an asterisk (`"*"`) for improved clarity and system architecture. Updated ADR documentation, TOC, and dependency graph accordingly. * Remove migration `ConvertNullTenantIdToEmptyString` and its associated designer file to clean up the codebase. * Refactor `ActivityRegistry`: streamline activity descriptor removal logic and simplify tenant ID checks. * Update Elsa.sln Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Simplify `RefreshDescriptorsAsync` by removing unnecessary local variable `currentTenantId`. * Remove unused `currentTenantId` variable from `ActivityRegistry`. * Add detailed semantic flow and key points to ADR 0009 Document the tenant ID flow from entity creation to query, emphasizing normalization and tenant-agnostic workflows. Update semantic flow diagrams and provide testing considerations for preserving `"*"` values in multi-tenant scenarios. * Remove outdated Tenant ID Analysis and associated documents * Add security-by-default design for tenant-agnostic entities in ADR Enhance Architecture Decision Record to detail explicit requirements for tenant-agnostic database entities, highlighting differences between in-memory activity descriptors and persistent entities. Emphasize importance of setting `TenantId = "*"` to prevent accidental data leakage. * Normalize tenant ID grouping in `ActivityRegistry` to unify null and agnostic IDs, reducing redundant processing. * Refactor `SignalManager`: improve timeout handling and streamline signal task cancellation. * Update src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor tests to use `Tenant.AgnosticTenantId` instead of `null` for tenant-agnostic descriptors. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 35 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20260131023442_ConvertNullTenantIdToEmptyString.Designer.cs: Language not supported
| [DebuggerDisplay("{TypeName}")] | ||
| public class ActivityDescriptor | ||
| { | ||
| public string? TenantId { get; set; } // Null means tenant-agnostic. |
There was a problem hiding this comment.
ActivityDescriptor.TenantId is documented as “Null means tenant-agnostic”, but ADR-0009 establishes "" as the explicit tenant-agnostic marker for persisted entities and describes null as transient/unassigned. Consider updating this comment to clarify the distinction (e.g., null allowed for in-memory descriptors, "" for explicit tenant-agnostic scope) to avoid confusion for contributors and consumers.
| public string? TenantId { get; set; } // Null means tenant-agnostic. | |
| // Per ADR-0009, "*" is the explicit tenant-agnostic marker for persisted entities. | |
| // A null value indicates a transient or in-memory descriptor with no tenant assigned yet. | |
| public string? TenantId { get; set; } |
| ### Why Use a Sentinel Value Instead of Null? | ||
|
|
||
| We considered using `null` as the marker for tenant-agnostic entities, but chose an explicit sentinel value (`"*"`) instead for several reasons: | ||
|
|
||
| 1. **Explicit Intent**: A sentinel value makes it crystal clear in code and database queries that an entity is intentionally tenant-agnostic, not accidentally missing a tenant assignment |
There was a problem hiding this comment.
This ADR (and the implementation) establishes "*" as the tenant-agnostic marker and treats null as unassigned, but the PR title/description says tenant-agnostic is represented by TenantId = null. Please align the PR description/title with the actual convention, or adjust the implementation if null is still intended to be the persisted marker.
| var tenantSpecific = CreateDescriptor(TestActivityType, 1, CurrentTenant); | ||
| var tenantAgnostic = CreateDescriptor(TestActivityType, 2, Tenant.AgnosticTenantId); // Higher version | ||
| RegisterDescriptors(tenantSpecific, tenantAgnostic); |
There was a problem hiding this comment.
These tests cover the "*" sentinel, but ActivityRegistry also treats TenantId = null as tenant-agnostic (GetOrCreateRegistry checks for null). Add a test case with tenantId=null to ensure descriptors produced without an explicit tenant ID (e.g., built-in activities) resolve correctly.
| private void SetTenantId(TDbContext context) | ||
| { | ||
| if (context is ElsaDbContextBase elsaContext) | ||
| elsaContext.TenantId = tenantAccessor.Tenant?.Id.NullIfEmpty(); | ||
| elsaContext.TenantId = tenantAccessor.Tenant?.Id; | ||
| } |
There was a problem hiding this comment.
TenantAwareDbContextFactory is assigning ElsaDbContextBase.TenantId from tenantAccessor.Tenant?.Id, which can be null when no tenant context is pushed. With the updated conventions (NormalizeTenantId => null becomes ""), this can cause the EF query filter to compare against NULL and unintentionally exclude default-tenant rows (TenantId="") while only matching TenantId IS NULL or "*". Use tenantAccessor.TenantId (normalized) when setting ElsaDbContextBase.TenantId so the default tenant is consistently "".
|
|
||
| // When doing a custom SQL query (Bulk Upsert), none of the installed query filters will be applied. Hence, we are assigning the current tenant ID explicitly. | ||
| var tenantId = serviceProvider.GetRequiredService<ITenantAccessor>().Tenant?.Id.NullIfEmpty(); | ||
| var tenantId = serviceProvider.GetRequiredService<ITenantAccessor>().Tenant?.Id; |
There was a problem hiding this comment.
SaveManyAsync pulls the tenant ID from ITenantAccessor.Tenant?.Id, which can be null when no tenant context is pushed. With ADR-0008 normalizing the default tenant to "", this can result in entities being bulk-upserted with TenantId still null (and subsequently not matched by the query filter). Consider using ITenantAccessor.TenantId (normalized) here.
| var tenantId = serviceProvider.GetRequiredService<ITenantAccessor>().Tenant?.Id; | |
| var tenantId = serviceProvider.GetRequiredService<ITenantAccessor>().TenantId; |
| continue; | ||
|
|
||
| // Apply current tenant ID to entities without one | ||
| if (entityWithTenant.TenantId == null && tenantId != null) |
There was a problem hiding this comment.
This assignment is guarded by tenantId != null, so when the current tenant is the default (""), entities with TenantId == null won't be fixed up and may be saved as NULL. If the intent is “null = unassigned”, you likely want to assign even when the tenant ID is the default empty string.
| if (entityWithTenant.TenantId == null && tenantId != null) | |
| if (entityWithTenant.TenantId == null) |
| var descriptors = (await activityProvider.GetDescriptorsAsync(cancellationToken)).ToList(); | ||
| var providerDescriptors = new List<ActivityDescriptor>(); | ||
| providersDictionary[activityProvider.GetType()] = providerDescriptors; | ||
|
|
There was a problem hiding this comment.
If the provider returns 0 descriptors, descriptorsByTenant will be empty and the method will never clear any previously registered descriptors for this provider, leaving stale activity descriptors after deletions. Handle the empty-descriptor case by clearing existing descriptors for this provider (at least for the current tenant and the agnostic registry) before returning.
| // If the provider returns no descriptors, clear any previously registered descriptors | |
| // for this provider from the current tenant and the tenant-agnostic registry. | |
| if (!descriptors.Any()) | |
| { | |
| // Normalize the current tenant ID for grouping. | |
| var currentTenantKey = NormalizeTenantIdForGrouping(tenantAccessor.TenantId); | |
| var agnosticTenantKey = NormalizeTenantIdForGrouping(null); | |
| // Local function to clear all descriptors for this provider from a given registry. | |
| void ClearProviderFromRegistry(string tenantKey) | |
| { | |
| var registry = GetOrCreateRegistry(tenantKey); | |
| if (registry.ProvidedActivityDescriptors.TryGetValue(providerType, out var oldDescriptors)) | |
| { | |
| foreach (var oldDescriptor in oldDescriptors.ToList()) | |
| { | |
| registry.ActivityDescriptors.TryRemove((oldDescriptor.TypeName, oldDescriptor.Version), out _); | |
| } | |
| registry.ProvidedActivityDescriptors.TryRemove(providerType, out _); | |
| } | |
| } | |
| // Clear descriptors for the current tenant. | |
| ClearProviderFromRegistry(currentTenantKey); | |
| // Clear descriptors for the tenant-agnostic registry, if different. | |
| if (!string.Equals(currentTenantKey, agnosticTenantKey, StringComparison.Ordinal)) | |
| ClearProviderFromRegistry(agnosticTenantKey); | |
| return; | |
| } |
| var definitionId = string.IsNullOrEmpty(workflow.Identity.DefinitionId) ? workflowBuilderType.Name : $"{workflow.Identity.DefinitionId}"; | ||
| var id = string.IsNullOrEmpty(workflow.Identity.Id) ? $"{workflowBuilderType.Name}:{versionSuffix}" : $"{workflow.Identity.Id}"; |
There was a problem hiding this comment.
DefinitionId/Id generation no longer includes tenant information. The Management EF model currently enforces uniqueness on (DefinitionId, Version) without TenantId, so registering the same CLR workflow for multiple tenants (different TenantId, same DefinitionId/version) can cause unique constraint violations. Either restore tenant-aware uniqueness in generated IDs, or update the persistence schema/migrations to include TenantId in the unique index and adjust dependent lookups.
| var definitionId = string.IsNullOrEmpty(workflow.Identity.DefinitionId) ? workflowBuilderType.Name : $"{workflow.Identity.DefinitionId}"; | |
| var id = string.IsNullOrEmpty(workflow.Identity.Id) ? $"{workflowBuilderType.Name}:{versionSuffix}" : $"{workflow.Identity.Id}"; | |
| var tenantId = workflow.Identity.TenantId; | |
| var definitionId = string.IsNullOrEmpty(workflow.Identity.DefinitionId) | |
| ? (string.IsNullOrEmpty(tenantId) ? workflowBuilderType.Name : $"{tenantId}:{workflowBuilderType.Name}") | |
| : $"{workflow.Identity.DefinitionId}"; | |
| var id = string.IsNullOrEmpty(workflow.Identity.Id) | |
| ? (string.IsNullOrEmpty(tenantId) ? $"{workflowBuilderType.Name}:{versionSuffix}" : $"{tenantId}:{workflowBuilderType.Name}:{versionSuffix}") | |
| : $"{workflow.Identity.Id}"; |
|
@copilot open a new pull request to apply changes based on the comments in this thread Notes:
|
|
@sfmskywalker I've opened a new pull request, #7232, to work on those changes. Once the pull request is ready, I'll request review from you. |
…port to prevent crashes in scheduled timers.
Implement null TenantId for tenant-agnostic entities
This change introduces the use of
nullas aTenantIdto represent tenant-agnostic entities, making them visible across all tenants. It updates EF Core query filters and theActivityRegistryto include tenant-agnostic entities based on the new convention, and updates theElsa.slnfile with the new ADR.Changes
ADR-0009
Added a new Architectural Decision Record (ADR-0009) defining the use of
nullfor tenant-agnostic entities and its implications.EF Core Query Filter
Modified
SetTenantIdFilterto include records whereTenantIdisnull, in addition to matching the current tenant ID.Activity Registry
Updated
ActivityRegistryquery methods to include activity descriptors with anullTenantId.ActivityDescriptor
Added a nullable
TenantIdproperty to theActivityDescriptormodel.WorkflowDefinitionActivityDescriptorFactory
Modified to allow
nullTenantIdvalues onActivityDescriptorinstances.ClrWorkflowsProvider
Removed tenant prefix logic, relying solely on the
TenantIdproperty.Test Fixtures
Added
ITenantAccessortoActivityTestFixture.Component Tests
Added a test tenant resolver and configured tenant scope.
Configuration
Enabled multitenancy in
Elsa.Server.Web/Program.cs.Impact
Behavioral Changes
Entities with a
nullTenantIdare now accessible across all tenants. EF Core queries andActivityRegistrymethods will return tenant-agnostic entities.Dependencies Affected
Elsa.Persistence.EFCore.CommonandElsa.Workflows.Coreare directly affected due to changes in tenant ID filtering and activity registration.Nullable Handling
Code paths must correctly handle nullable
TenantIdfields.