Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
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;

ObjectConverter.StrictMode = true;
Expand Down
Loading
Loading