doc/adr/0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md
Date: 2026-01-31
Accepted
The multitenancy system in Elsa supports both tenant-specific and tenant-agnostic entities. The convention established in ADR-0008 uses an empty string ("") as the tenant ID for the default tenant. However, we also need a way to represent entities that are tenant-agnostic - entities that should be visible and accessible across all tenants.
Previously, the system did not properly distinguish between:
TenantId = "")This caused issues where:
ActivityRegistry used a single global dictionary that was replaced during tenant activation via Interlocked.Exchange, causing race conditionsWe considered using null as the marker for tenant-agnostic entities, but chose an explicit sentinel value ("*") instead for several reasons:
(string TenantId, string Type, int Version) which would become (string? TenantId, ...)TenantId = '*' are more explicit than TenantId IS NULL"*" in logs immediately signals tenant-agnostic behaviorWe will use the asterisk character ("*") as a sentinel value to represent tenant-agnostic entities - entities that should be accessible across all tenants. This decision includes:
"*" (represented by constant Tenant.AgnosticTenantId) = tenant-agnostic (visible to all tenants)"" (represented by constant Tenant.DefaultTenantId) = default tenant (visible only to default tenant)null = not yet assigned (will be normalized to either agnostic or current tenant by handlers)Implement a three-dictionary architecture in ActivityRegistry to properly isolate tenant-specific and tenant-agnostic descriptors:
_tenantRegistries: ConcurrentDictionary<string, TenantRegistryData> - Per-tenant activity descriptors (e.g., workflow-as-activities)_agnosticRegistry: TenantRegistryData - Shared tenant-agnostic descriptors (e.g., built-in activities)_manualActivityDescriptors: ISet<ActivityDescriptor> - Legacy support for manually registered activitiesKey behaviors:
TenantId = null or TenantId = "*" are stored in _agnosticRegistryTenantId are stored in the corresponding tenant's registry in _tenantRegistriesRefreshDescriptorsAsync() updates only the affected tenant's registry, not the entire global dictionaryUpdate SetTenantIdFilter to return records where:
TenantId == dbContext.TenantId (tenant-specific match), ORTenantId == "*" (tenant-agnostic records)Update ApplyTenantId handler to:
TenantId = "*" (don't overwrite tenant-agnostic entities)TenantId = nullImportant: This is a security-by-default design. Entities with null tenant ID are never automatically converted to tenant-agnostic ("*"). They are always assigned to the current tenant context. To create tenant-agnostic database entities, developers must explicitly set TenantId = "*". This prevents accidental data leakage across tenants.
The asterisk character "*" is reserved and cannot be used as an actual tenant ID. Tenant creation and validation logic should reject any attempt to create a tenant with ID "*".
"*" sentinel makes intent clear in code, logs, and database(string TenantId, ...) instead of (string? TenantId, ...)WHERE TenantId = current_tenant OR TenantId = '*' is more explicit than null checksInterlocked.Exchange and its race conditions"*" character cannot be used as an actual tenant ID (low impact, as tenant IDs are typically alphanumeric)"*" (agnostic) and "" (default tenant)null for agnostic entities would need data migrationUnderstanding how tenant IDs flow through the system is critical:
┌─────────────────────────────────────────────────────────────┐
│ Entity Creation / Deserialization │
├─────────────────────────────────────────────────────────────┤
│ TenantId = null → Not yet assigned │
│ TenantId = "*" → Explicitly agnostic │
│ TenantId = "" → Default tenant │
│ TenantId = "foo" → Specific tenant "foo" │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ApplyTenantId Handler (before DB save) │
├─────────────────────────────────────────────────────────────┤
│ TenantId = "*" → PRESERVED (agnostic) │
│ TenantId = null → SET to current tenant from context │
│ TenantId = "" → PRESERVED (default tenant) │
│ TenantId = "foo" → PRESERVED (specific tenant) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Database Storage │
├─────────────────────────────────────────────────────────────┤
│ TenantId = "*" → Stored as "*" (agnostic) │
│ TenantId = "" → Stored as "" (default tenant) │
│ TenantId = "foo" → Stored as "foo" (specific tenant) │
│ NOTE: No null values in DB after ApplyTenantId handler │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ SetTenantIdFilter (EF Core Query) │
├─────────────────────────────────────────────────────────────┤
│ Returns: TenantId == current_tenant OR TenantId == "*" │
│ Result: Tenant-specific records + agnostic records │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ActivityRegistry (In-Memory) │
├─────────────────────────────────────────────────────────────┤
│ null or "*" → _agnosticRegistry (shared) │
│ TenantId="" → _tenantRegistries[""] (default) │
│ TenantId=X → _tenantRegistries[X] (specific tenant X) │
└─────────────────────────────────────────────────────────────┘
Key Points:
null is transient: It only exists during entity creation/deserialization before ApplyTenantId runs"*" is permanent: Once set, it's preserved and stored in the database as-isNormalizeTenantId() converts null → "": This ensures null becomes the default tenant, NOT agnosticApplyTenantId handler, all entities have non-null tenant IDs"*": The query filter explicitly compares against the string "*", not nullnull and "*" as agnosticWhen Find(string type) is called:
The GetOrCreateRegistry() method treats both null and "*" as agnostic:
if (tenantId is null or Tenant.AgnosticTenantId)
return _agnosticRegistry;
This provides flexibility for in-memory operations where activity descriptors might temporarily have null tenant IDs before normalization.
The system treats in-memory activity descriptors and persistent database entities differently for security and architectural reasons:
TenantId = null by ActivityDescriberTenantId = definition.TenantId by WorkflowDefinitionActivityDescriptorFactorynull is acceptable here because descriptors are recreated on each startup and mapped to _agnosticRegistryTenantId = "*" to be tenant-agnosticTenantId = null is never converted to "*" - always assigned to current tenantTenantId creates a tenant-specific entity, not a global oneExample - Creating Tenant-Agnostic Workflow:
{
"tenantId": "*",
"name": "GlobalApprovalWorkflow",
"description": "Shared across all tenants",
"root": { ... }
}
Why This Asymmetry Is Important:
"*")When workflows are imported from providers (e.g., blob storage):
tenantId field in their JSON have TenantId = nullNormalizeTenantId() extensionApplyTenantId handler assigns the current tenant from context"tenantId": "*" in the workflow JSON"*" value will be preserved through import, save, and query operations"")"*")"*" is preserved through save operations"*" entities are returned for all tenant contexts