Multi-Tenancy
GrydAuth provides comprehensive multi-tenancy support for SaaS applications, including tenant isolation, federation, and cross-tenant access patterns.
Multi-Tenancy Models
GrydAuth supports multiple tenancy models:
| Model | Description | Use Case |
|---|---|---|
| Database per Tenant | Separate database for each tenant | Maximum isolation |
| Schema per Tenant | Separate schema in shared database | Good isolation, easier management |
| Shared Database | All tenants in one database with TenantId column | Cost-effective, simple |
Quick Setup
1. Enable Multi-Tenancy
Configure in appsettings.json:
{
"MultiTenancy": {
"IsEnabled": true
}
}2. Configure GrydAuth
// Program.cs
builder.Services.AddGrydAuth(builder.Configuration);
// Middleware pipeline (order matters!)
app.UseAuthentication();
app.UseGrydAuth(); // Handles tenant resolution from JWT
app.UseAuthorization();3. Apply Tenant Filter to Entities
using Gryd.Domain.Abstractions;
public class Product : BaseEntity<Guid>, IHasTenant
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
// Multi-tenancy
public Guid TenantId { get; set; }
}Tenant Resolution Strategies
GrydAuth primarily uses Claim-Based Resolution from JWT tokens. The tenant_id claim is automatically extracted from the authenticated token.
Header-Based Resolution (X-App-Id)
For application-specific routing, GrydAuth uses the X-App-Id header:
POST /api/auth/login
X-App-Id: my-frontend-app
Content-Type: application/json
{
"email": "user@example.com",
"password": "Password123!"
}Claim-Based Resolution (Primary)
Tenant ID is extracted from the tenant_id claim in the JWT token automatically by UseGrydAuth() middleware.
{
"sub": "user-id",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"permissions": ["read:products"]
}Smart Federation
GrydAuth implements Smart Federation for multi-tenant authentication:
- Login - Returns Global Token (if user has multiple tenants)
- Switch Tenant - Exchange Global Token for Tenant Token
- Auto-Switch - If user has only one tenant, automatically issues Tenant Token
POST /api/auth/switch-tenant
Authorization: Bearer {global-or-tenant-token}
Content-Type: application/json
{
"tenantId": "550e8400-e29b-41d4-a716-446655440002"
}Tenant Management
Creating Tenants
POST /api/tenants
Authorization: Bearer {super-admin-token}
Content-Type: application/json
{
"name": "Acme Corporation",
"identifier": "acme",
"domain": "acme.yourapp.com",
"settings": {
"timezone": "America/New_York",
"language": "en-US",
"features": ["advanced-reports", "api-access"]
}
}Tenant Entity
public class Tenant : AggregateRoot<Guid>
{
public string Name { get; private set; } = string.Empty;
public string Identifier { get; private set; } = string.Empty;
public string? Domain { get; private set; }
public bool IsActive { get; private set; } = true;
public DateTime CreatedAt { get; private set; }
// Settings stored as JSON
public TenantSettings Settings { get; private set; } = new();
// Navigation
public ICollection<UserTenant> UserTenants { get; private set; } = new List<UserTenant>();
}User-Tenant Relationship
Assigning Users to Tenants
POST /api/tenants/{tenantId}/users
Authorization: Bearer {admin-token}
Content-Type: application/json
{
"userId": "550e8400-e29b-41d4-a716-446655440001",
"roles": ["Manager", "User"]
}User Belonging to Multiple Tenants
public class UserTenant : BaseEntity<Guid>
{
public Guid UserId { get; set; }
public User User { get; set; } = null!;
public Guid TenantId { get; set; }
public Tenant Tenant { get; set; } = null!;
// Roles within this tenant
public ICollection<Role> Roles { get; set; } = new List<Role>();
public bool IsDefault { get; set; }
public DateTime JoinedAt { get; set; }
}Tenant Isolation
Automatic Query Filtering
GrydAuth automatically filters queries by tenant:
public class ProductRepository : RepositoryBase<Product>
{
protected override IQueryable<Product> ApplyFilters(IQueryable<Product> query)
{
// Automatic tenant filter is applied by GrydAuth
// You don't need to add TenantId filter manually
return base.ApplyFilters(query);
}
}Configuring Tenant Filter
public class AppDbContext : GrydAuthDbContext
{
private readonly ITenantContext _tenantContext;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply tenant filter to all entities implementing IHasTenant
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(IHasTenant).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.AddQueryFilter<IHasTenant>(e => e.TenantId == _tenantContext.TenantId);
}
}
}
}Bypassing Tenant Filter
For administrative operations:
public class TenantService
{
private readonly AppDbContext _context;
public async Task<List<Product>> GetAllProductsAcrossTenantsAsync()
{
// Bypass tenant filter for cross-tenant queries
return await _context.Products
.IgnoreQueryFilters()
.ToListAsync();
}
}Tenant Federation
Smart Federation
Users can belong to multiple tenants with different roles:
// User logs in and selects tenant
var loginResult = await _authService.LoginAsync(new LoginRequest
{
Email = "user@example.com",
Password = "password",
TenantId = selectedTenantId // User selects which tenant to access
});Switching Tenants
POST /api/auth/switch-tenant
Authorization: Bearer {current-token}
Content-Type: application/json
{
"tenantId": "550e8400-e29b-41d4-a716-446655440002"
}Response:
{
"isSuccess": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g...",
"expiresAt": "2026-01-29T14:00:00Z",
"isGlobal": false,
"tokenType": "Tenant",
"currentTenant": {
"id": "550e8400-e29b-41d4-a716-446655440002",
"name": "Another Corp",
"isDefault": false
}
}
}Global Tokens
GrydAuth uses Global Tokens for multi-tenant authentication:
- Global Token: 2 minutes TTL, scope
tenant-selector-only - Tenant Token: 60 minutes TTL, full access within tenant
When a user with multiple tenants logs in without specifying a preferredTenantId:
- A Global Token is issued
- User must call
POST /api/auth/switch-tenantto select a tenant - A full Tenant Token is returned
Tenant Context
Accessing Current Tenant
public class ProductService
{
private readonly ITenantContext _tenantContext;
private readonly ICurrentUserService _currentUser;
public async Task<Product> CreateAsync(CreateProductRequest request)
{
// Get current tenant ID
var tenantId = _tenantContext.TenantId;
// Or from current user
var userTenantId = _currentUser.TenantId;
var product = new Product
{
Name = request.Name,
TenantId = tenantId!.Value
};
return product;
}
}ITenantContext Properties
| Property | Type | Description |
|---|---|---|
TenantId | Guid? | Current tenant ID |
Tenant | Tenant? | Full tenant entity (lazy loaded) |
IsResolved | bool | Whether tenant was successfully resolved |
ResolutionMethod | string | How tenant was resolved (Header, Subdomain, etc.) |
Tenant-Specific Configuration
Per-Tenant Settings
public class TenantSettings
{
public string Timezone { get; set; } = "UTC";
public string Language { get; set; } = "en-US";
public string Currency { get; set; } = "USD";
public HashSet<string> EnabledFeatures { get; set; } = new();
public Dictionary<string, object> CustomSettings { get; set; } = new();
}
// Usage
var tenant = await _tenantRepository.GetByIdAsync(tenantId);
var timezone = tenant.Settings.Timezone;Feature Flags per Tenant
public class FeatureService
{
private readonly ITenantContext _tenantContext;
public bool IsFeatureEnabled(string featureName)
{
return _tenantContext.Tenant?.Settings.EnabledFeatures.Contains(featureName) ?? false;
}
}
// Usage in controller
if (_featureService.IsFeatureEnabled("advanced-reports"))
{
return await GenerateAdvancedReportAsync();
}
else
{
return BadRequest("This feature is not available for your tenant.");
}Database Strategies
Shared Database (Recommended)
All tenants in one database with TenantId column:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection")));Database per Tenant
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var tenantContext = serviceProvider.GetRequiredService<ITenantContext>();
var connectionString = GetTenantConnectionString(tenantContext.TenantId);
options.UseNpgsql(connectionString);
});
private string GetTenantConnectionString(Guid? tenantId)
{
if (tenantId == null)
return Configuration.GetConnectionString("MasterConnection")!;
return Configuration.GetConnectionString($"Tenant_{tenantId}")
?? Configuration.GetConnectionString("DefaultConnection")!;
}Best Practices
- Always Use IHasTenant - Mark all tenant-specific entities
- Test Tenant Isolation - Write tests to verify data isolation
- Audit Cross-Tenant Access - Log when admins bypass tenant filters
- Use Tenant Middleware Early - Resolve tenant before authentication
- Cache Tenant Data - Tenant resolution can be performance-critical
- Handle Missing Tenant - Define behavior when tenant cannot be resolved