Tenant Resolution Strategies
Learn how PearDrop identifies which tenant a request belongs to using Finbuckle.MultiTenant strategies.
Resolution Flow
graph TD
A[HTTP Request] --> B{Host Strategy}
B -->|Found| G[Load Tenant Info]
B -->|Not Found| C{Route Strategy}
C -->|Found| G
C -->|Not Found| D{Claims Strategy}
D -->|Found| G
D -->|Not Found| E{Default or Redirect}
E --> F[Use Default Tenant]
E --> H[Redirect to noTenantRedirectPath]
G --> I[Set TenantInfo in Context]
I --> J[Execute Request]
Strategies execute in registration order. First match wins.
Host Strategy (Subdomain-Based)
Extract tenant identifier from subdomain.
Configuration
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithHostStrategy();
DNS Setup
Wildcard DNS record:
Type: A
Name: *.myapp.com
Value: 203.0.113.45 (your server IP)
Result:
tenant1.myapp.com→ 203.0.113.45acmecorp.myapp.com→ 203.0.113.45newclient.myapp.com→ 203.0.113.45
Resolution Logic
URL: https://acmecorp.myapp.com/dashboard
Host: acmecorp.myapp.com
Extract: acmecorp (first segment before base domain)
Lookup: Find tenant with identifier "acmecorp"
Edge cases:
myapp.com→ No subdomain → No tenant or defaultadmin.myapp.com→ MatchesownerHost→ No tenant (admin mode)www.myapp.com→wwwtreated as tenant identifierlocalhost→ No tenant resolution
Development with localhost
Option 1: Use hosts file
# C:\Windows\System32\drivers\etc\hosts (Windows)
# /etc/hosts (Linux/Mac)
127.0.0.1 tenant1.localhost
127.0.0.1 tenant2.localhost
127.0.0.1 admin.localhost
Access:
http://tenant1.localhost:5000→ tenant1http://admin.localhost:5000→ admin/owner
Option 2: Use .test TLD
# Install dnsmasq or similar local DNS
# Configure to resolve *.test to 127.0.0.1
Access:
http://tenant1.test:5000http://admin.test:5000
SSL Certificates
Development: Use self-signed wildcard certificate
# Create wildcard cert for *.localhost
dotnet dev-certs https --trust
Production: Use real wildcard certificate
- Order from CA:
*.myapp.com - Or use Let's Encrypt with DNS challenge
- Configure in Kestrel/IIS/nginx
Override Owner Host
Exclude certain hosts from tenant resolution:
{
"PearDrop": {
"modules": {
"multitenancy": {
"ownerHost": "admin.myapp.com"
}
}
}
}
admin.myapp.com → No tenant (system admin)
tenant1.myapp.com → Tenant "tenant1"
Use cases:
- Admin portal
- System monitoring dashboards
- Cross-tenant reporting
- Tenant provisioning UI
Route Strategy (URL Path-Based)
Extract tenant identifier from URL path.
Configuration
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithRouteStrategy("tenant"); // Route parameter name
Route Setup
MVC/API:
app.MapControllerRoute(
name: "tenant_route",
pattern: "{tenant}/{controller=Home}/{action=Index}/{id?}");
Minimal APIs:
app.MapGet("/{tenant}/dashboard", (string tenant) =>
{
// tenant parameter automatically captured
return Results.Ok($"Dashboard for {tenant}");
});
Resolution Logic
URL: https://myapp.com/acmecorp/orders/12345
Path: /acmecorp/orders/12345
Extract: acmecorp (first segment, matches "tenant" parameter)
Lookup: Find tenant with identifier "acmecorp"
Examples:
/tenant1/dashboard→ tenant1/acmecorp/api/orders→ acmecorp/admin/users→ admin (if exists as tenant)
Nested Routes
// More complex pattern
app.MapControllerRoute(
name: "tenant_area",
pattern: "{tenant}/{area:exists}/{controller}/{action}/{id?}");
/tenant1/Admin/Users/Edit/123 → tenant1
/acmecorp/Reports/Sales/View → acmecorp
Combining with Static Content
Serve static files without tenant:
// Static files before routing
app.UseStaticFiles();
// Then routing with tenant
app.UseRouting();
app.UseMultiTenant();
/css/site.css → Static file (no tenant)
/tenant1/dashboard → Routed with tenant
API Versioning
app.MapControllerRoute(
name: "api_tenant",
pattern: "api/v{version:apiVersion}/{tenant}/{controller}/{action?}");
/api/v1/tenant1/orders → tenant1
/api/v2/acmecorp/orders → acmecorp
Authentication Strategy (Claims-Based)
Extract tenant identifier from authenticated user's claims.
Configuration
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithClaimStrategy("TenantId"); // Claim type
Adding Claims During Login
public async Task<IActionResult> Login(LoginRequest request)
{
// Authenticate user
var user = await authenticateUser(request.Email, request.Password);
// Get user's tenant
var tenant = await getTenantForUser(user.Id);
// Create claims with tenant
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim("TenantId", tenant.Identifier), // Used by strategy
new Claim("TenantName", tenant.Name) // Optional, for display
};
var identity = new ClaimsIdentity(claims, "peardrop");
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(principal);
return RedirectToAction("Dashboard");
}
Resolution Logic
User authenticated → Check claims
Find claim with type "TenantId"
Value: "acmecorp"
Lookup: Find tenant with identifier "acmecorp"
Requirements:
- User must be authenticated
- Claim must exist in ClaimsPrincipal
- Claim value must match tenant identifier
Multi-Tenant Users
User belongs to one tenant:
new Claim("TenantId", "tenant1")
User belongs to multiple tenants:
// Store primary/preferred tenant
new Claim("TenantId", "tenant1"),
new Claim("AllowedTenants", "tenant1,tenant2,tenant3")
Switch tenant:
[HttpPost("switch-tenant")]
public async Task<IActionResult> SwitchTenant([FromBody] string newTenantId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var allowedTenants = User.FindFirst("AllowedTenants")?.Value.Split(',');
if (!allowedTenants.Contains(newTenantId))
return Forbid();
// Re-issue ticket with new TenantId claim
var claims = User.Claims
.Where(c => c.Type != "TenantId")
.Append(new Claim("TenantId", newTenantId));
var identity = new ClaimsIdentity(claims, User.Identity.AuthenticationType);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(principal);
return Ok();
}
JWT with Claims
// Generate JWT with tenant claim
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(config["Jwt:Secret"]);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim("TenantId", tenant.Identifier)
}),
Expires = DateTime.UtcNow.AddHours(1),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
var tokenString = tokenHandler.WriteToken(token);
return Ok(new { token = tokenString });
Client sends JWT:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
Server extracts TenantId claim → Resolves tenant
Combined Strategies
Chain multiple strategies with fallback logic.
Host with Route Fallback
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithHostStrategy() // Try subdomain first
.WithRouteStrategy("tenant"); // Fallback to route
Behavior:
tenant1.myapp.com/dashboard → Host strategy finds "tenant1"
myapp.com/tenant2/orders → Host strategy fails, route finds "tenant2"
Use case: Public site with subdomains + admin portal with route-based access
All Three Strategies
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithHostStrategy()
.WithRouteStrategy("tenant")
.WithClaimStrategy("TenantId");
Resolution order:
- Check host/subdomain
- If not found, check route parameter
- If not found, check authentication claims
- If not found, use default or redirect
Scenario:
Request: myapp.com/api/orders
- Host: myapp.com → No subdomain
- Route: /api/orders → No "tenant" parameter
- Claims: User has "TenantId" claim = "tenant1" → SUCCESS
Priority Override
Higher priority strategies checked first:
// Claims highest priority
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithClaimStrategy("TenantId") // Check claims first
.WithHostStrategy() // Then subdomain
.WithRouteStrategy("tenant"); // Finally route
Use case: SaaS app where user context overrides URL
Custom Resolution Strategy
Implement IMultiTenantStrategy for custom logic.
Example: Header-Based Strategy
public class HeaderTenantStrategy : IMultiTenantStrategy
{
private const string HeaderName = "X-Tenant-ID";
public async Task<string?> GetIdentifierAsync(object context)
{
if (context is not HttpContext httpContext)
return null;
// Extract from custom header
if (httpContext.Request.Headers.TryGetValue(HeaderName, out var headerValue))
{
return headerValue.ToString();
}
return null;
}
}
// Register
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithStrategy<HeaderTenantStrategy>(ServiceLifetime.Scoped);
Client usage:
GET /api/orders
Host: myapp.com
X-Tenant-ID: acmecorp
Example: Query String Strategy
public class QueryStringTenantStrategy : IMultiTenantStrategy
{
private const string QueryParam = "tenant";
public async Task<string?> GetIdentifierAsync(object context)
{
if (context is not HttpContext httpContext)
return null;
// Extract from query string
if (httpContext.Request.Query.TryGetValue(QueryParam, out var queryValue))
{
return queryValue.ToString();
}
return null;
}
}
// Register
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithStrategy<QueryStringTenantStrategy>(ServiceLifetime.Scoped);
Usage:
https://myapp.com/dashboard?tenant=acmecorp
Example: Database User-Tenant Mapping
public class DatabaseUserTenantStrategy : IMultiTenantStrategy
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IUserTenantRepository userTenantRepo;
public DatabaseUserTenantStrategy(
IHttpContextAccessor httpContextAccessor,
IUserTenantRepository userTenantRepo)
{
this.httpContextAccessor = httpContextAccessor;
this.userTenantRepo = userTenantRepo;
}
public async Task<string?> GetIdentifierAsync(object context)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
return null;
// Get authenticated user
var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier);
if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
return null;
// Look up user's tenant in database
var tenantMapping = await userTenantRepo.GetTenantForUserAsync(userId);
return tenantMapping?.TenantIdentifier;
}
}
// Register
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithStrategy<DatabaseUserTenantStrategy>(ServiceLifetime.Scoped);
Debugging Resolution
Log Strategy Results
public class LoggingTenantStrategy : IMultiTenantStrategy
{
private readonly IMultiTenantStrategy innerStrategy;
private readonly ILogger<LoggingTenantStrategy> logger;
public LoggingTenantStrategy(
IMultiTenantStrategy innerStrategy,
ILogger<LoggingTenantStrategy> logger)
{
this.innerStrategy = innerStrategy;
this.logger = logger;
}
public async Task<string?> GetIdentifierAsync(object context)
{
var identifier = await innerStrategy.GetIdentifierAsync(context);
logger.LogInformation(
"Strategy {StrategyName} resolved tenant: {Identifier}",
innerStrategy.GetType().Name,
identifier ?? "(null)");
return identifier;
}
}
Access Tenant Info in Code
[HttpGet("current-tenant")]
public IActionResult GetCurrentTenant([FromServices] IMultiTenantContextAccessor accessor)
{
var tenantInfo = accessor.MultiTenantContext?.TenantInfo;
if (tenantInfo == null)
return Ok(new { message = "No tenant context" });
return Ok(new
{
id = tenantInfo.Id,
identifier = tenantInfo.Identifier,
name = tenantInfo.Name
});
}
Middleware Diagnostics
app.Use(async (context, next) =>
{
var accessor = context.RequestServices
.GetRequiredService<IMultiTenantContextAccessor>();
var tenantInfo = accessor.MultiTenantContext?.TenantInfo;
Console.WriteLine($"Request: {context.Request.Path}");
Console.WriteLine($"Tenant: {tenantInfo?.Identifier ?? "(none)"}");
await next();
});
Next Steps
- Configuration - Complete settings reference
- Tenant Management - Create and manage tenants
- Data Isolation - Schema and database-per-tenant patterns