Skip to main content

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.45
  • acmecorp.myapp.com → 203.0.113.45
  • newclient.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 default
  • admin.myapp.com → Matches ownerHost → No tenant (admin mode)
  • www.myapp.comwww treated as tenant identifier
  • localhost → 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 → tenant1
  • http://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:5000
  • http://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:

  1. Check host/subdomain
  2. If not found, check route parameter
  3. If not found, check authentication claims
  4. 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