Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
3 changes: 3 additions & 0 deletions Elsa.sln
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{7D
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
ProjectSection(SolutionItems) = preProject
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}"
EndProject
Expand Down Expand Up @@ -203,6 +205,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
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
doc\adr\0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md = doc\adr\0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"
Expand Down
224 changes: 224 additions & 0 deletions doc/adr/0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# 9. Asterisk Sentinel Value for Tenant-Agnostic Entities

Date: 2026-01-31

## Status

Accepted

## Context

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:
1. **Default tenant entities** (should only be visible to the default tenant with `TenantId = ""`)
2. **Tenant-agnostic entities** (should be visible to all tenants regardless of their tenant context)

This caused issues where:
- Activity descriptors for built-in activities were being wiped out when different tenants were activated
- The `ActivityRegistry` used a single global dictionary that was replaced during tenant activation via `Interlocked.Exchange`, causing race conditions
- EF Core query filters only matched records with an exact tenant ID match, excluding tenant-agnostic records
- Workflows with no explicit tenant ID could not be found when a specific tenant context was active

### 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
Comment on lines +23 to +27

Copilot AI Feb 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
2. **Simpler Composite Keys**: Avoids nullable handling complexity in composite keys like `(string TenantId, string Type, int Version)` which would become `(string? TenantId, ...)`
3. **Clearer SQL Queries**: Database queries with `TenantId = '*'` are more explicit than `TenantId IS NULL`
4. **Better Logging**: Seeing `"*"` in logs immediately signals tenant-agnostic behavior
5. **Architecture Alignment**: Works seamlessly with the three-dictionary ActivityRegistry architecture where agnostic entities have their own dedicated registry

## Decision

We 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:

### 1. Convention

- `"*"` (represented by constant `Tenant.AgnosticTenantId`) = tenant-agnostic (visible to all tenants)
- `""` (represented by constant `Tenant.DefaultTenantId`) = default tenant (visible only to default tenant)
- Any other non-null string = specific tenant (visible only to that tenant)
- `null` = not yet assigned (will be normalized to either agnostic or current tenant by handlers)

### 2. Activity Registry Architecture

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 activities

Key behaviors:
- Descriptors with `TenantId = null` or `TenantId = "*"` are stored in `_agnosticRegistry`
- Descriptors with any other `TenantId` are stored in the corresponding tenant's registry in `_tenantRegistries`
- `RefreshDescriptorsAsync()` updates only the affected tenant's registry, not the entire global dictionary
- Find methods **always prefer tenant-specific descriptors over agnostic ones**, even if agnostic has a higher version number

### 3. EF Core Query Filter

Update `SetTenantIdFilter` to return records where:
- `TenantId == dbContext.TenantId` (tenant-specific match), OR
- `TenantId == "*"` (tenant-agnostic records)

### 4. Entity Handlers

Update `ApplyTenantId` handler to:
- Preserve `TenantId = "*"` (don't overwrite tenant-agnostic entities)
- Only apply current tenant ID to entities with `TenantId = null`

**Important: 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.

### 5. Reserved Character Constraint

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 `"*"`.

## Consequences

### Positive

- **Explicit tenant-agnostic marking**: The `"*"` sentinel makes intent clear in code, logs, and database
- **Proper tenant isolation**: The three-dictionary architecture prevents tenant activation from wiping out other tenants' descriptors
- **No nullable handling**: Composite keys remain `(string TenantId, ...)` instead of `(string? TenantId, ...)`
- **Tenant precedence**: Tenant-specific descriptors always take precedence over agnostic ones, allowing tenants to override built-in activities
- **Dynamic tenant management**: Tenants can be activated and deactivated at runtime without affecting each other
- **Database efficiency**: Tenant-agnostic entities are stored once and accessible to all tenants
- **Clear SQL queries**: `WHERE TenantId = current_tenant OR TenantId = '*'` is more explicit than null checks
- **Thread safety**: Per-tenant dictionaries eliminate the need for `Interlocked.Exchange` and its race conditions

### Negative

- **Reserved character**: The `"*"` character cannot be used as an actual tenant ID (low impact, as tenant IDs are typically alphanumeric)
- **Two conventions**: Developers must understand the distinction between `"*"` (agnostic) and `""` (default tenant)
- **Migration complexity**: Existing systems using `null` for agnostic entities would need data migration

### Neutral

- Using a sentinel value for special cases is a common pattern in software architecture
- The distinction between default tenant and tenant-agnostic is fundamental to proper multitenancy design
- The three-dictionary architecture adds complexity but is necessary for correct tenant isolation

## Implementation Notes

### Semantic Flow: From Entity Creation to Query

Understanding 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-is
- **`NormalizeTenantId()` converts `null` → `""`**: This ensures `null` becomes the default tenant, NOT agnostic
- **Database has no nulls**: After `ApplyTenantId` handler, all entities have non-null tenant IDs
- **EF Core filters check for `"*"`**: The query filter explicitly compares against the string `"*"`, not null
- **ActivityRegistry accepts both**: For flexibility, in-memory registry treats both `null` and `"*"` as agnostic

### ActivityRegistry Behavior

When `Find(string type)` is called:
1. First check the current tenant's registry for matching descriptors
2. If found, return the highest version from the tenant-specific registry
3. Only if no tenant-specific descriptor exists, fall back to the agnostic registry
4. This ensures tenant-specific customizations always take precedence

The `GetOrCreateRegistry()` method treats both `null` and `"*"` as agnostic:
```csharp
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.

### Activity Descriptors vs Database Entities: Different Rules

The system treats **in-memory activity descriptors** and **persistent database entities** differently for security and architectural reasons:

#### In-Memory Activity Descriptors (Ephemeral)
- Created on startup by activity providers
- **Built-in activities** (WriteLine, SetVariable, etc.): Created with `TenantId = null` by `ActivityDescriber`
- **Workflow-as-activities**: Created with `TenantId = definition.TenantId` by `WorkflowDefinitionActivityDescriptorFactory`
- `null` is acceptable here because descriptors are recreated on each startup and mapped to `_agnosticRegistry`
- No security risk: descriptors don't contain sensitive data, just metadata about activity types

#### Persistent Database Entities (WorkflowDefinition, etc.)
- Stored permanently in the database
- **Must explicitly set `TenantId = "*"`** to be tenant-agnostic
- `TenantId = null` is **never** converted to `"*"` - always assigned to current tenant
- **Security-by-default**: Prevents accidental data leakage across tenants
- A developer who forgets to set `TenantId` creates a tenant-specific entity, not a global one

**Example - Creating Tenant-Agnostic Workflow:**
```json
{
"tenantId": "*",
"name": "GlobalApprovalWorkflow",
"description": "Shared across all tenants",
"root": { ... }
}
```

**Why This Asymmetry Is Important:**
1. **Safety**: Database entities with null tenant ID default to current tenant (safe)
2. **Explicitness**: Tenant-agnostic entities must be intentional (require `"*"`)
3. **Different lifecycles**: Descriptors are ephemeral, entities are persistent
4. **Backward compatibility**: Built-in activities work without modification

### Workflow Import Behavior

When workflows are imported from providers (e.g., blob storage):
- Workflows without an explicit `tenantId` field in their JSON have `TenantId = null`
- During import, these are normalized to the current tenant ID via `NormalizeTenantId()` extension
- When saved to database, `ApplyTenantId` handler assigns the current tenant from context
- To create truly tenant-agnostic workflows, explicitly set `"tenantId": "*"` in the workflow JSON
- The `"*"` value will be preserved through import, save, and query operations

### Testing Considerations

- Component tests use the default tenant (`""`)
- Built-in activities use the agnostic marker (`"*"`)
- Tenant-specific tests should create explicit tenant contexts to verify proper isolation
- Unit tests should verify that `"*"` is preserved through save operations
- Integration tests should verify that `"*"` entities are returned for all tenant contexts
38 changes: 20 additions & 18 deletions doc/adr/graph.dot
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
digraph {
node [shape=plaintext];
subgraph {
_1 [label="1. Record architecture decisions"; URL="0001-record-architecture-decisions.html"];
_2 [label="2. Fault Propagation from Child to Parent Activities"; URL="0002-fault-propagation-from-child-to-parent-activities.html"];
_1 -> _2 [style="dotted", weight=1];
_3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003-direct-bookmark-management-in-workflowexecutioncontext.html"];
_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. 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];
}
node [shape=plaintext];
subgraph {
_1 [label="1. Record architecture decisions"; URL="0001-record-architecture-decisions.html"];
_2 [label="2. Fault Propagation from Child to Parent Activities"; URL="0002-fault-propagation-from-child-to-parent-activities.html"];
_1 -> _2 [style="dotted", weight=1];
_3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003-direct-bookmark-management-in-workflowexecutioncontext.html"];
_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. 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];
_9 [label="9. Asterisk Sentinel Value for Tenant-Agnostic Entities"; URL="0009-asterisk-sentinel-value-for-tenant-agnostic-entities.html"];
_8 -> _9 [style="dotted", weight=1];
}
}
3 changes: 2 additions & 1 deletion doc/adr/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
* [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)
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)
* [9. Asterisk Sentinel Value for Tenant-Agnostic Entities](0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md)
2 changes: 1 addition & 1 deletion src/apps/Elsa.Server.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,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;
Comment thread
sfmskywalker marked this conversation as resolved.

ObjectConverter.StrictMode = true;
Expand Down
Loading
Loading