|
| 1 | +# 9. Asterisk Sentinel Value for Tenant-Agnostic Entities |
| 2 | + |
| 3 | +Date: 2026-01-31 |
| 4 | + |
| 5 | +## Status |
| 6 | + |
| 7 | +Accepted |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +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. |
| 12 | + |
| 13 | +Previously, the system did not properly distinguish between: |
| 14 | +1. **Default tenant entities** (should only be visible to the default tenant with `TenantId = ""`) |
| 15 | +2. **Tenant-agnostic entities** (should be visible to all tenants regardless of their tenant context) |
| 16 | + |
| 17 | +This caused issues where: |
| 18 | +- Activity descriptors for built-in activities were being wiped out when different tenants were activated |
| 19 | +- The `ActivityRegistry` used a single global dictionary that was replaced during tenant activation via `Interlocked.Exchange`, causing race conditions |
| 20 | +- EF Core query filters only matched records with an exact tenant ID match, excluding tenant-agnostic records |
| 21 | +- Workflows with no explicit tenant ID could not be found when a specific tenant context was active |
| 22 | + |
| 23 | +### Why Use a Sentinel Value Instead of Null? |
| 24 | + |
| 25 | +We considered using `null` as the marker for tenant-agnostic entities, but chose an explicit sentinel value (`"*"`) instead for several reasons: |
| 26 | + |
| 27 | +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 |
| 28 | +2. **Simpler Composite Keys**: Avoids nullable handling complexity in composite keys like `(string TenantId, string Type, int Version)` which would become `(string? TenantId, ...)` |
| 29 | +3. **Clearer SQL Queries**: Database queries with `TenantId = '*'` are more explicit than `TenantId IS NULL` |
| 30 | +4. **Better Logging**: Seeing `"*"` in logs immediately signals tenant-agnostic behavior |
| 31 | +5. **Architecture Alignment**: Works seamlessly with the three-dictionary ActivityRegistry architecture where agnostic entities have their own dedicated registry |
| 32 | + |
| 33 | +## Decision |
| 34 | + |
| 35 | +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: |
| 36 | + |
| 37 | +### 1. Convention |
| 38 | + |
| 39 | +- `"*"` (represented by constant `Tenant.AgnosticTenantId`) = tenant-agnostic (visible to all tenants) |
| 40 | +- `""` (represented by constant `Tenant.DefaultTenantId`) = default tenant (visible only to default tenant) |
| 41 | +- Any other non-null string = specific tenant (visible only to that tenant) |
| 42 | +- `null` = not yet assigned (will be normalized to either agnostic or current tenant by handlers) |
| 43 | + |
| 44 | +### 2. Activity Registry Architecture |
| 45 | + |
| 46 | +Implement a **three-dictionary architecture** in `ActivityRegistry` to properly isolate tenant-specific and tenant-agnostic descriptors: |
| 47 | + |
| 48 | +- **`_tenantRegistries`**: `ConcurrentDictionary<string, TenantRegistryData>` - Per-tenant activity descriptors (e.g., workflow-as-activities) |
| 49 | +- **`_agnosticRegistry`**: `TenantRegistryData` - Shared tenant-agnostic descriptors (e.g., built-in activities) |
| 50 | +- **`_manualActivityDescriptors`**: `ISet<ActivityDescriptor>` - Legacy support for manually registered activities |
| 51 | + |
| 52 | +Key behaviors: |
| 53 | +- Descriptors with `TenantId = null` or `TenantId = "*"` are stored in `_agnosticRegistry` |
| 54 | +- Descriptors with any other `TenantId` are stored in the corresponding tenant's registry in `_tenantRegistries` |
| 55 | +- `RefreshDescriptorsAsync()` updates only the affected tenant's registry, not the entire global dictionary |
| 56 | +- Find methods **always prefer tenant-specific descriptors over agnostic ones**, even if agnostic has a higher version number |
| 57 | + |
| 58 | +### 3. EF Core Query Filter |
| 59 | + |
| 60 | +Update `SetTenantIdFilter` to return records where: |
| 61 | +- `TenantId == dbContext.TenantId` (tenant-specific match), OR |
| 62 | +- `TenantId == "*"` (tenant-agnostic records) |
| 63 | + |
| 64 | +### 4. Entity Handlers |
| 65 | + |
| 66 | +Update `ApplyTenantId` handler to: |
| 67 | +- Preserve `TenantId = "*"` (don't overwrite tenant-agnostic entities) |
| 68 | +- Only apply current tenant ID to entities with `TenantId = null` |
| 69 | + |
| 70 | +**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. |
| 71 | + |
| 72 | +### 5. Reserved Character Constraint |
| 73 | + |
| 74 | +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 `"*"`. |
| 75 | + |
| 76 | +## Consequences |
| 77 | + |
| 78 | +### Positive |
| 79 | + |
| 80 | +- **Explicit tenant-agnostic marking**: The `"*"` sentinel makes intent clear in code, logs, and database |
| 81 | +- **Proper tenant isolation**: The three-dictionary architecture prevents tenant activation from wiping out other tenants' descriptors |
| 82 | +- **No nullable handling**: Composite keys remain `(string TenantId, ...)` instead of `(string? TenantId, ...)` |
| 83 | +- **Tenant precedence**: Tenant-specific descriptors always take precedence over agnostic ones, allowing tenants to override built-in activities |
| 84 | +- **Dynamic tenant management**: Tenants can be activated and deactivated at runtime without affecting each other |
| 85 | +- **Database efficiency**: Tenant-agnostic entities are stored once and accessible to all tenants |
| 86 | +- **Clear SQL queries**: `WHERE TenantId = current_tenant OR TenantId = '*'` is more explicit than null checks |
| 87 | +- **Thread safety**: Per-tenant dictionaries eliminate the need for `Interlocked.Exchange` and its race conditions |
| 88 | + |
| 89 | +### Negative |
| 90 | + |
| 91 | +- **Reserved character**: The `"*"` character cannot be used as an actual tenant ID (low impact, as tenant IDs are typically alphanumeric) |
| 92 | +- **Two conventions**: Developers must understand the distinction between `"*"` (agnostic) and `""` (default tenant) |
| 93 | +- **Migration complexity**: Existing systems using `null` for agnostic entities would need data migration |
| 94 | + |
| 95 | +### Neutral |
| 96 | + |
| 97 | +- Using a sentinel value for special cases is a common pattern in software architecture |
| 98 | +- The distinction between default tenant and tenant-agnostic is fundamental to proper multitenancy design |
| 99 | +- The three-dictionary architecture adds complexity but is necessary for correct tenant isolation |
| 100 | + |
| 101 | +## Implementation Notes |
| 102 | + |
| 103 | +### Semantic Flow: From Entity Creation to Query |
| 104 | + |
| 105 | +Understanding how tenant IDs flow through the system is critical: |
| 106 | + |
| 107 | +``` |
| 108 | +┌─────────────────────────────────────────────────────────────┐ |
| 109 | +│ Entity Creation / Deserialization │ |
| 110 | +├─────────────────────────────────────────────────────────────┤ |
| 111 | +│ TenantId = null → Not yet assigned │ |
| 112 | +│ TenantId = "*" → Explicitly agnostic │ |
| 113 | +│ TenantId = "" → Default tenant │ |
| 114 | +│ TenantId = "foo" → Specific tenant "foo" │ |
| 115 | +└─────────────────────────────────────────────────────────────┘ |
| 116 | + ↓ |
| 117 | +┌─────────────────────────────────────────────────────────────┐ |
| 118 | +│ ApplyTenantId Handler (before DB save) │ |
| 119 | +├─────────────────────────────────────────────────────────────┤ |
| 120 | +│ TenantId = "*" → PRESERVED (agnostic) │ |
| 121 | +│ TenantId = null → SET to current tenant from context │ |
| 122 | +│ TenantId = "" → PRESERVED (default tenant) │ |
| 123 | +│ TenantId = "foo" → PRESERVED (specific tenant) │ |
| 124 | +└─────────────────────────────────────────────────────────────┘ |
| 125 | + ↓ |
| 126 | +┌─────────────────────────────────────────────────────────────┐ |
| 127 | +│ Database Storage │ |
| 128 | +├─────────────────────────────────────────────────────────────┤ |
| 129 | +│ TenantId = "*" → Stored as "*" (agnostic) │ |
| 130 | +│ TenantId = "" → Stored as "" (default tenant) │ |
| 131 | +│ TenantId = "foo" → Stored as "foo" (specific tenant) │ |
| 132 | +│ NOTE: No null values in DB after ApplyTenantId handler │ |
| 133 | +└─────────────────────────────────────────────────────────────┘ |
| 134 | + ↓ |
| 135 | +┌─────────────────────────────────────────────────────────────┐ |
| 136 | +│ SetTenantIdFilter (EF Core Query) │ |
| 137 | +├─────────────────────────────────────────────────────────────┤ |
| 138 | +│ Returns: TenantId == current_tenant OR TenantId == "*" │ |
| 139 | +│ Result: Tenant-specific records + agnostic records │ |
| 140 | +└─────────────────────────────────────────────────────────────┘ |
| 141 | + ↓ |
| 142 | +┌─────────────────────────────────────────────────────────────┐ |
| 143 | +│ ActivityRegistry (In-Memory) │ |
| 144 | +├─────────────────────────────────────────────────────────────┤ |
| 145 | +│ null or "*" → _agnosticRegistry (shared) │ |
| 146 | +│ TenantId="" → _tenantRegistries[""] (default) │ |
| 147 | +│ TenantId=X → _tenantRegistries[X] (specific tenant X) │ |
| 148 | +└─────────────────────────────────────────────────────────────┘ |
| 149 | +``` |
| 150 | + |
| 151 | +**Key Points:** |
| 152 | +- **`null` is transient**: It only exists during entity creation/deserialization before `ApplyTenantId` runs |
| 153 | +- **`"*"` is permanent**: Once set, it's preserved and stored in the database as-is |
| 154 | +- **`NormalizeTenantId()` converts `null` → `""`**: This ensures `null` becomes the default tenant, NOT agnostic |
| 155 | +- **Database has no nulls**: After `ApplyTenantId` handler, all entities have non-null tenant IDs |
| 156 | +- **EF Core filters check for `"*"`**: The query filter explicitly compares against the string `"*"`, not null |
| 157 | +- **ActivityRegistry accepts both**: For flexibility, in-memory registry treats both `null` and `"*"` as agnostic |
| 158 | + |
| 159 | +### ActivityRegistry Behavior |
| 160 | + |
| 161 | +When `Find(string type)` is called: |
| 162 | +1. First check the current tenant's registry for matching descriptors |
| 163 | +2. If found, return the highest version from the tenant-specific registry |
| 164 | +3. Only if no tenant-specific descriptor exists, fall back to the agnostic registry |
| 165 | +4. This ensures tenant-specific customizations always take precedence |
| 166 | + |
| 167 | +The `GetOrCreateRegistry()` method treats both `null` and `"*"` as agnostic: |
| 168 | +```csharp |
| 169 | +if (tenantId is null or Tenant.AgnosticTenantId) |
| 170 | + return _agnosticRegistry; |
| 171 | +``` |
| 172 | + |
| 173 | +This provides flexibility for in-memory operations where activity descriptors might temporarily have `null` tenant IDs before normalization. |
| 174 | + |
| 175 | +### Activity Descriptors vs Database Entities: Different Rules |
| 176 | + |
| 177 | +The system treats **in-memory activity descriptors** and **persistent database entities** differently for security and architectural reasons: |
| 178 | + |
| 179 | +#### In-Memory Activity Descriptors (Ephemeral) |
| 180 | +- Created on startup by activity providers |
| 181 | +- **Built-in activities** (WriteLine, SetVariable, etc.): Created with `TenantId = null` by `ActivityDescriber` |
| 182 | +- **Workflow-as-activities**: Created with `TenantId = definition.TenantId` by `WorkflowDefinitionActivityDescriptorFactory` |
| 183 | +- `null` is acceptable here because descriptors are recreated on each startup and mapped to `_agnosticRegistry` |
| 184 | +- No security risk: descriptors don't contain sensitive data, just metadata about activity types |
| 185 | + |
| 186 | +#### Persistent Database Entities (WorkflowDefinition, etc.) |
| 187 | +- Stored permanently in the database |
| 188 | +- **Must explicitly set `TenantId = "*"`** to be tenant-agnostic |
| 189 | +- `TenantId = null` is **never** converted to `"*"` - always assigned to current tenant |
| 190 | +- **Security-by-default**: Prevents accidental data leakage across tenants |
| 191 | +- A developer who forgets to set `TenantId` creates a tenant-specific entity, not a global one |
| 192 | + |
| 193 | +**Example - Creating Tenant-Agnostic Workflow:** |
| 194 | +```json |
| 195 | +{ |
| 196 | + "tenantId": "*", |
| 197 | + "name": "GlobalApprovalWorkflow", |
| 198 | + "description": "Shared across all tenants", |
| 199 | + "root": { ... } |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +**Why This Asymmetry Is Important:** |
| 204 | +1. **Safety**: Database entities with null tenant ID default to current tenant (safe) |
| 205 | +2. **Explicitness**: Tenant-agnostic entities must be intentional (require `"*"`) |
| 206 | +3. **Different lifecycles**: Descriptors are ephemeral, entities are persistent |
| 207 | +4. **Backward compatibility**: Built-in activities work without modification |
| 208 | + |
| 209 | +### Workflow Import Behavior |
| 210 | + |
| 211 | +When workflows are imported from providers (e.g., blob storage): |
| 212 | +- Workflows without an explicit `tenantId` field in their JSON have `TenantId = null` |
| 213 | +- During import, these are normalized to the current tenant ID via `NormalizeTenantId()` extension |
| 214 | +- When saved to database, `ApplyTenantId` handler assigns the current tenant from context |
| 215 | +- To create truly tenant-agnostic workflows, explicitly set `"tenantId": "*"` in the workflow JSON |
| 216 | +- The `"*"` value will be preserved through import, save, and query operations |
| 217 | + |
| 218 | +### Testing Considerations |
| 219 | + |
| 220 | +- Component tests use the default tenant (`""`) |
| 221 | +- Built-in activities use the agnostic marker (`"*"`) |
| 222 | +- Tenant-specific tests should create explicit tenant contexts to verify proper isolation |
| 223 | +- Unit tests should verify that `"*"` is preserved through save operations |
| 224 | +- Integration tests should verify that `"*"` entities are returned for all tenant contexts |
0 commit comments