“Switch tenant” can describe three mechanisms with very different security properties. Treating them as interchangeable hides both audit gaps and data-isolation risk.
1. Three distinct mechanisms
| Mechanism | Intended caller | Evidence | Current runtime snapshot | Revocation |
|---|---|---|---|---|
X-Tenant or path | authenticated user/application | must equal authenticated tenant | ordinary CurrentTenant | authentication lifecycle |
X-Operate-As-Tenant / Debug | Host or Development | caller type or environment only | ordinary CurrentTenant | none per session |
| formal tenant impersonation | privileged operator | TenantImpersonationGrant and token | four-field impersonation snapshot | expiry or grant deletion |
The first is a confirmation hint, the second is a privileged override, and only the third is a formal impersonation session.
2. Formal grant model
TenantImpersonationGrant is owned by the operator tenant and records:
- operator tenant and operator user;
- target tenant;
- validity window;
- Scope;
- MaxSessions;
- RequiresStepUp.
The aggregate’s TenantId equals the operator tenant, not the target. This matters for owner-local storage and authorization.
3. What token issuance enforces
TenantImpersonationTokenService.IssueAsync currently:
- rejects nested user impersonation;
- rejects an empty target;
- rejects switching to the operator’s own tenant;
- requires a grant returned by the store;
- requires the grant time window to be active;
- caps token expiry at the earlier of two hours and grant expiry.
public static class TenantImpersonationErrors{ public static readonly Error Nested = Error.Forbidden("TenantImpersonation.Nested", "Nested impersonation is forbidden.");}
if (currentUser.IsImpersonating){ // Reject before grant lookup so no nested session can be issued. return Result.Failure<TenantImpersonationTokenDescriptor>( TenantImpersonationErrors.Nested);}
// The service must receive operator facts from authentication, not request-body fields.var issued = await tokenService.IssueAsync( operatorTenantId: currentTenant.Tenant.ActorTenantId, operatorUserId: currentUser.UserId!.Value.ToString(CultureInfo.InvariantCulture), targetTenantId: command.TargetTenantId, cancellationToken);The result is a descriptor, not a signed JWT. It contains operator, target, grant, scope, issued time, and expiry facts.
4. Current authorization gaps
The model contains controls that the service does not yet enforce:
| Control | Stored | Enforced during issuance or request |
|---|---|---|
| validity window | yes | yes |
| same-tenant and nested restrictions | derived | yes |
| Scope | yes | returned in descriptor only |
| RequiresStepUp | yes | no |
| MaxSessions | yes | no |
| target tenant existence/status | target id | no |
| operator user status | operator id | no |
| grant owner equals supplied operator tenant | owner TenantId | not compared |
IsStillAuthorizedAsync(grantId, operatorTenantId) validates non-empty arguments, loads by GrantId, and checks time. It does not compare the loaded grant’s owner TenantId with the supplied operator tenant.
5. Request-time middleware
When CurrentTenant.IsImpersonating is true, TenantImpersonationTokenMiddleware:
- returns 401
TenantImpersonation.Expiredwhen the snapshot expiry has passed; - returns 401
TenantImpersonation.Unavailablewhen the service is not registered; - returns 401
TenantImpersonation.Revokedwhen the grant is missing or no longer active.
It delegates when no formal impersonation tuple is installed. Consequently, X-Operate-As-Tenant does not activate these checks.
6. Missing HTTP bridge
The source tree contains the grant, service, descriptor, and validation middleware. It does not contain a complete HTTP bridge that:
- exposes an authorized issue endpoint;
- requires step-up when configured;
- signs the descriptor into claims;
- authenticates those claims on later requests;
- reconstructs the full
CurrentTenanttuple; - records issue, use, revocation, and exit events.
This diagram is the target protocol, not a claim that the current Host already implements it.
7. Host operate-as data-surface defect
The override middleware installs new CurrentTenant(targetTenantId, OfficeId: user.OfficeId). It does not set original tenant, impersonator, grant, or expiry. It also skips the status guard.
Both ORM providers then see an authenticated Host and bypass tenant filtering entirely. The effective target value exists, but ordinary Store queries remain platform-wide.
The repair should separate two capabilities:
- operate as target tenant: normal Stores filter to the target and every resource uses that EffectiveTenantId;
- platform-wide query: a dedicated port guarded by a separate permission, reason, approval, limits, and audit.
8. Audit contract
A formal impersonation audit record should preserve:
| Field | Meaning |
|---|---|
| EffectiveTenantId | tenant whose data was accessed |
| ActorTenantId | operator’s home tenant |
| EffectiveUserId | business user identity, if user impersonation also applies |
| ImpersonatorUserId | privileged human operator |
| GrantId and session id | authorization and revocation evidence |
| Scope and reason | intended operational boundary |
| CorrelationId and TraceId | end-to-end request chain |
The audit trail should record both successful use and rejection. Do not store the complete token.
9. Endpoint contract example
group.MapPost("/tenant-impersonation/sessions", async ( StartTenantImpersonationRequest request, ICurrentUser currentUser, ITenantImpersonationOrchestrator orchestrator, CancellationToken cancellationToken) =>{ // The orchestrator owns permission, approval, step-up, grant, target-status, and audit checks. var result = await orchestrator.StartAsync( operatorUserId: currentUser.UserId!.Value, targetTenantId: request.TargetTenantId, reason: request.Reason, cancellationToken);
// Return a short-lived session token; never expose the grant aggregate itself. return result.ToHttpResult();}).RequireAuthorization("platform.tenancy.impersonation.start");This is an integration design based on existing types, not source copied from an existing endpoint.
10. Security test matrix
- grant owner mismatch is rejected even when GrantId exists;
- suspended, deactivated, or unknown target tenant is rejected;
RequiresStepUpcannot be bypassed;- Scope limits both endpoint permission and data surface;
MaxSessionsis enforced atomically across instances;- deleting the grant invalidates the next request;
- operate-as and formal impersonation filter both ORM providers to the target;
- cache keys, files, search, and reports use EffectiveTenantId;
- audit preserves operator and target facts.
11. Source review
# Review grant fields, issuance checks, and request-time validation as one protocol.rg -n "TenantImpersonationGrant|IssueAsync|IsStillAuthorizedAsync" \ src/Platform/Identity src/Hosts/BitzOrcas.Api -g '*.cs'
# Confirm whether an HTTP issue and claim-reconstruction bridge now exists.rg -n "TenantImpersonationTokenDescriptor|TenantImpersonationGrantId|RequiresStepUp" \ src/Hosts src/Platform/Identity -g '*.cs'
# Review every privileged override alongside ORM Host bypass.rg -n "X-Operate-As-Tenant|IsTenantFilterBypass|isHostBypass" \ src/Hosts src/Framework -g '*.cs'Back: CurrentTenant and persistence · Next: Background work and resources