Multi-tenancy та ізоляція даних в ASP.NET Core
Multi-tenancy та ізоляція даних в ASP.NET Core
1. Три стратегії ізоляції даних
1.1 Shared Database + TenantId column
Усі tenants — в одній БД, але кожен запис має TenantId колонку. EF Core QueryFilter автоматично додає WHERE TenantId = @CurrentTenant до всіх запитів.
┌─────────────────────────────────────────┐
│ PostgreSQL │
│ ┌──────────────────────────────────┐ │
│ │ Products │ │
│ │ Id | TenantId | Name | Price │ │
│ │ 1 | acme | Coffee | 4.99 │ │
│ │ 2 | beta | Tea | 2.99 │ │
│ │ 3 | acme | Latte | 5.49 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
+ Плюси: простота, низька вартість, легке масштабування. - Мінуси: ризик «витоку» якщо QueryFilter пропустити, складний compliance (GDPR, data residency).
1.2 Shared Database + Separate Schemas
Кожен tenant має власну PostgreSQL schema (acme.products, beta.products), але в одній БД.
┌─────────────────────────────────────────────┐
│ PostgreSQL │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ schema: acme │ │ schema: beta │ │
│ │ ├─ Products │ │ ├─ Products │ │
│ │ ├─ Orders │ │ ├─ Orders │ │
│ │ └─ Users │ │ └─ Users │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
+ Плюси: сильніша ізоляція ніж discriminator, легший backup/restore по tenant. - Мінуси: складніше управління схемами, Cross-tenant queries важчі.
1.3 Separate Databases
Кожен tenant — окрема База Даних. Максимальна ізоляція.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ acme-db │ │ beta-db │ │ gamma-db │
│ Products │ │ Products │ │ Products │
│ Orders │ │ Orders │ │ Orders │
└─────────────┘ └─────────────┘ └─────────────┘
+ Плюси: повна ізоляція, data residency (EU tenant → EU сервер), легше compliance. - Мінуси: висока вартість, складне управління, N баз → N connection pools.
Порівняльна таблиця
| Критерій | Shared DB + Column | Shared DB + Schema | Separate DB |
|---|---|---|---|
| Ізоляція | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Вартість | 💚 Низька | 💛 Середня | 🔴 Висока |
| Compliance | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Складність | 💚 Низька | 💛 Середня | 🔴 Висока |
| Scale-out | 💚 Просто | 💛 Середньо | 🔴 Складно |
2. ITenantProvider: інтерфейс ізоляції
Ключовий компонент — сервіс, що визначає поточний tenant із запиту:
public interface ITenantProvider
{
string? TenantId { get; }
bool IsValid { get; }
}
public interface ITenantResolver
{
Task<string?> ResolveAsync(HttpContext context);
}
Модель Tenant
public class Tenant
{
public string Id { get; set; } = null!; // "acme"
public string Name { get; set; } = null!; // "ACME Corp"
public string? DatabaseSchema { get; set; } // "acme" (для schema-based)
public string? ConnectionString { get; set; } // Для separate DB
public string? CustomDomain { get; set; } // "app.acme.com"
public bool IsActive { get; set; } = true;
public string Plan { get; set; } = "free"; // free/pro/enterprise
public Dictionary<string, string> Settings { get; set; } = [];
}
3. Resolver: як визначити поточний tenant?
3.1 Через поддомен (Subdomain)
public class SubdomainTenantResolver : ITenantResolver
{
private readonly ITenantRepository _tenantRepo;
public SubdomainTenantResolver(ITenantRepository tenantRepo)
=> _tenantRepo = tenantRepo;
public async Task<string?> ResolveAsync(HttpContext context)
{
var host = context.Request.Host.Host; // "acme.myapp.com"
var parts = host.Split('.');
// Перший сегмент = tenant ID (якщо не "www" або "api")
if (parts.Length < 3)
return null; // Кореневий домен без субдомену
var subdomain = parts[0].ToLower();
if (subdomain is "www" or "api" or "app")
return null;
// Валідуємо через репозиторій
var tenant = await _tenantRepo.GetByIdAsync(subdomain);
return tenant?.IsActive == true ? subdomain : null;
}
}
3.2 Через JWT Claims (рекомендовано для SPA/API)
public class JwtClaimTenantResolver : ITenantResolver
{
private const string TenantClaimType = "tenant_id";
public Task<string?> ResolveAsync(HttpContext context)
{
// JWT вже розпізнаний middleware — claims доступні через User
var tenantId = context.User.FindFirst(TenantClaimType)?.Value;
return Task.FromResult(tenantId);
}
}
public string GenerateToken(AppUser user, string tenantId)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new("tenant_id", tenantId), // ← ключовий claim
// ...
};
// ...
}
3.3 Через заголовок X-Tenant-Id
public class HeaderTenantResolver : ITenantResolver
{
private readonly ITenantRepository _tenantRepo;
public HeaderTenantResolver(ITenantRepository tenantRepo)
=> _tenantRepo = tenantRepo;
public async Task<string?> ResolveAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(
"X-Tenant-Id", out var tenantId))
return null;
var id = tenantId.ToString();
var tenant = await _tenantRepo.GetByIdAsync(id);
return tenant?.IsActive == true ? id : null;
}
}
Composite Resolver (пробуємо кілька методів)
public class CompositeTenantResolver : ITenantResolver
{
private readonly IEnumerable<ITenantResolver> _resolvers;
public CompositeTenantResolver(
JwtClaimTenantResolver jwtResolver,
HeaderTenantResolver headerResolver,
SubdomainTenantResolver subdomainResolver)
{
// Порядок важливий: JWT > Header > Subdomain
_resolvers = [jwtResolver, headerResolver, subdomainResolver];
}
public async Task<string?> ResolveAsync(HttpContext context)
{
foreach (var resolver in _resolvers)
{
var tenantId = await resolver.ResolveAsync(context);
if (tenantId is not null)
return tenantId;
}
return null;
}
}
4. Middleware: встановити Tenant у контекст
public class TenantMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantMiddleware> _logger;
public TenantMiddleware(
RequestDelegate next,
ILogger<TenantMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(
HttpContext ctx,
ITenantResolver resolver,
ITenantProvider tenantProvider)
{
var tenantId = await resolver.ResolveAsync(ctx);
if (tenantId is null)
{
// Можна відхилити запит без tenant
// АБО дозволити (для загальних ендпоінтів: /health, /auth/login)
if (ctx.Request.Path.StartsWithSegments("/api"))
{
_logger.LogWarning(
"Request to API without tenant from {IP}",
ctx.Connection.RemoteIpAddress);
ctx.Response.StatusCode = 400;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync("""
{"error":"Tenant not specified. Include tenant in JWT, X-Tenant-Id header, or subdomain."}
""");
return;
}
}
// Встановлюємо tenant у provider (scoped)
((TenantProvider)tenantProvider).SetTenant(tenantId);
_logger.LogDebug("Request for tenant: {TenantId}", tenantId ?? "none");
await _next(ctx);
}
}
public class TenantProvider : ITenantProvider
{
private string? _tenantId;
public string? TenantId => _tenantId;
public bool IsValid => _tenantId is not null;
// Викликається TenantMiddleware
internal void SetTenant(string? tenantId)
=> _tenantId = tenantId;
}
5. Стратегія 1: Shared Database + QueryFilters
Multi-tenant DbContext
public class AppDbContext : DbContext
{
private readonly ITenantProvider _tenantProvider;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
// Автоматичний Global Query Filter для ВСІХ tenant-aware сутностей
// КОЖЕН запит автоматично отримає WHERE TenantId = @currentTenant
builder.Entity<Product>()
.HasQueryFilter(p =>
p.TenantId == _tenantProvider.TenantId);
builder.Entity<Order>()
.HasQueryFilter(o =>
o.TenantId == _tenantProvider.TenantId);
// Складені індекси: (TenantId, Id) — для ефективного пошуку
builder.Entity<Product>()
.HasIndex(p => new { p.TenantId, p.Id });
builder.Entity<Order>()
.HasIndex(o => new { o.TenantId, o.Id });
}
// Автоматично встановлюємо TenantId при збереженні
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var tenantId = _tenantProvider.TenantId
?? throw new InvalidOperationException(
"Cannot save changes without a tenant context.");
foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
{
if (entry.State == EntityState.Added)
entry.Entity.TenantId = tenantId;
// Захист: перевіряємо, що оновлюємо лише свій tenant
if (entry.State == EntityState.Modified &&
entry.Entity.TenantId != tenantId)
throw new UnauthorizedAccessException(
$"Cannot modify entity belonging to tenant '{entry.Entity.TenantId}'.");
}
return base.SaveChangesAsync(ct);
}
}
Інтерфейс для tenant-aware сутностей
public interface ITenantEntity
{
string TenantId { get; set; }
}
// Базовий клас (опціонально)
public abstract class TenantEntity : ITenantEntity
{
public int Id { get; set; }
public string TenantId { get; set; } = null!;
}
// Приклад використання
public class Product : TenantEntity
{
public string Name { get; set; } = null!;
public decimal Price { get; set; }
}
Попередження: IgnoreQueryFilters
IgnoreQueryFilters() вимикає QueryFilter — використовуйте ЛИШЕ для admin-операцій!
// ✅ Безпечно: admin endpoint з явним tenant фільтром
var allTenantProducts = await db.Products
.IgnoreQueryFilters()
.Where(p => p.TenantId == "acme") // явний фільтр!
.ToListAsync();
// ❌ НЕБЕЗПЕЧНО: всі дані всіх tenants видимі!
var allProducts = await db.Products
.IgnoreQueryFilters()
.ToListAsync(); // НЕ використовуйте так!
6. Стратегія 2: Separate Schemas (PostgreSQL)
public class TenantSchemaDbContext : DbContext
{
private readonly ITenantProvider _tenantProvider;
private readonly ITenantRepository _tenantRepo;
public TenantSchemaDbContext(
DbContextOptions<TenantSchemaDbContext> options,
ITenantProvider tenantProvider,
ITenantRepository tenantRepo)
: base(options)
{
_tenantProvider = tenantProvider;
_tenantRepo = tenantRepo;
}
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
// Встановлюємо schema для всіх таблиць
var schema = _tenantProvider.TenantId ?? "public";
builder.HasDefaultSchema(schema);
builder.Entity<Product>().ToTable("Products");
// Результат: "acme"."Products", "beta"."Products"
}
}
// Фабрика DbContext (для динамічного schema)
public class TenantDbContextFactory
: IDbContextFactory<TenantSchemaDbContext>
{
private readonly IServiceProvider _services;
public TenantDbContextFactory(IServiceProvider services)
=> _services = services;
public TenantSchemaDbContext CreateDbContext()
{
var options = _services
.GetRequiredService<DbContextOptions<TenantSchemaDbContext>>();
var provider = _services
.GetRequiredService<ITenantProvider>();
var repo = _services
.GetRequiredService<ITenantRepository>();
return new TenantSchemaDbContext(options, provider, repo);
}
}
Міграції для schema-based multi-tenancy
public class TenantMigrationService
{
private readonly ITenantRepository _tenantRepo;
private readonly IServiceProvider _services;
private readonly ILogger<TenantMigrationService> _logger;
public TenantMigrationService(
ITenantRepository tenantRepo,
IServiceProvider services,
ILogger<TenantMigrationService> logger)
{
_tenantRepo = tenantRepo;
_services = services;
_logger = logger;
}
public async Task MigrateAllTenantsAsync()
{
var tenants = await _tenantRepo.GetAllActiveAsync();
foreach (var tenant in tenants)
{
await MigrateTenantAsync(tenant.Id);
}
}
public async Task MigrateTenantAsync(string tenantId)
{
_logger.LogInformation(
"Running migrations for tenant: {TenantId}", tenantId);
using var scope = _services.CreateScope();
// Встановлюємо tenant контекст для цього scope
var tenantProvider = (TenantProvider)scope.ServiceProvider
.GetRequiredService<ITenantProvider>();
tenantProvider.SetTenant(tenantId);
var db = scope.ServiceProvider
.GetRequiredService<TenantSchemaDbContext>();
// Спочатку створюємо schema
await db.Database.ExecuteSqlRawAsync(
$"CREATE SCHEMA IF NOT EXISTS \"{tenantId}\"");
// Потім мігруємо
await db.Database.MigrateAsync();
_logger.LogInformation(
"Migrations complete for tenant: {TenantId}", tenantId);
}
}
7. Стратегія 3: Separate Databases
public class TenantConnectionResolver
{
private readonly ITenantRepository _tenantRepo;
private readonly IConfiguration _config;
public TenantConnectionResolver(
ITenantRepository tenantRepo,
IConfiguration config)
{
_tenantRepo = tenantRepo;
_config = config;
}
public async Task<string> GetConnectionStringAsync(string tenantId)
{
var tenant = await _tenantRepo.GetByIdAsync(tenantId);
if (tenant?.ConnectionString is not null)
return tenant.ConnectionString; // Власна БД
// Fallback на шаблон
var template = _config.GetConnectionString("TenantTemplate")!;
return template.Replace("{tenant}", tenantId);
}
}
public class MultiDbContextFactory
{
private readonly TenantConnectionResolver _connectionResolver;
private readonly ITenantProvider _tenantProvider;
public MultiDbContextFactory(
TenantConnectionResolver connectionResolver,
ITenantProvider tenantProvider)
{
_connectionResolver = connectionResolver;
_tenantProvider = tenantProvider;
}
public async Task<AppDbContext> CreateContextAsync()
{
if (!_tenantProvider.IsValid)
throw new InvalidOperationException("No tenant context.");
var connectionString = await _connectionResolver
.GetConnectionStringAsync(_tenantProvider.TenantId!);
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql(connectionString);
return new AppDbContext(optionsBuilder.Options);
}
}
8. Tenant Registration та Provisioning
app.MapPost("/admin/tenants",
async (CreateTenantRequest req,
ITenantRepository tenantRepo,
TenantMigrationService migrationService,
ILogger<Program> logger) =>
{
// Перевірка унікальності ID
if (await tenantRepo.ExistsAsync(req.Id))
return Results.Conflict(new
{
error = $"Tenant '{req.Id}' already exists."
});
// Валідація ID: лише lowercase letters, digits, hyphens
if (!System.Text.RegularExpressions.Regex.IsMatch(
req.Id, @"^[a-z0-9-]{3,50}$"))
return Results.BadRequest(new
{
error = "Tenant ID must be 3-50 lowercase letters, digits, or hyphens."
});
var tenant = new Tenant
{
Id = req.Id,
Name = req.Name,
Plan = req.Plan ?? "free"
};
await tenantRepo.CreateAsync(tenant);
// Provisioning: створюємо schema/БД та мігруємо
try
{
await migrationService.MigrateTenantAsync(req.Id);
logger.LogInformation("Tenant {Id} provisioned successfully.", req.Id);
}
catch (Exception ex)
{
// Rollback: видаляємо tenant якщо provisioning не вдався
await tenantRepo.DeleteAsync(req.Id);
logger.LogError(ex, "Failed to provision tenant {Id}", req.Id);
return Results.Problem("Failed to provision tenant infrastructure.");
}
return Results.Created($"/admin/tenants/{req.Id}", new
{
tenant.Id,
tenant.Name,
tenant.Plan,
message = "Tenant created and provisioned successfully."
});
}).RequireAuthorization("admin");
record CreateTenantRequest(string Id, string Name, string? Plan);
9. Tenant-специфічна конфігурація
public class TenantSettingsService
{
private readonly ITenantRepository _tenantRepo;
private readonly IMemoryCache _cache;
public TenantSettingsService(
ITenantRepository tenantRepo,
IMemoryCache cache)
{
_tenantRepo = tenantRepo;
_cache = cache;
}
public async Task<T?> GetSettingAsync<T>(
string tenantId, string key)
{
var settings = await GetAllSettingsAsync(tenantId);
if (settings.TryGetValue(key, out var value))
{
try { return JsonSerializer.Deserialize<T>(value); }
catch { return default; }
}
return default;
}
public async Task<Dictionary<string, string>> GetAllSettingsAsync(
string tenantId)
{
var cacheKey = $"tenant_settings:{tenantId}";
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
var tenant = await _tenantRepo.GetByIdAsync(tenantId);
return tenant?.Settings ?? [];
}) ?? [];
}
public async Task SetSettingAsync(
string tenantId, string key, object value)
{
var tenant = await _tenantRepo.GetByIdAsync(tenantId);
if (tenant is null) throw new KeyNotFoundException(tenantId);
tenant.Settings[key] = JsonSerializer.Serialize(value);
await _tenantRepo.UpdateAsync(tenant);
// Інвалідуємо кеш
_cache.Remove($"tenant_settings:{tenantId}");
}
}
Типові tenant-специфічні налаштування
// Отримання та використання налаштувань
var maxUsers = await settingsService.GetSettingAsync<int>(
tenantId, "max_users"); // Ліміт за планом
var customLogo = await settingsService.GetSettingAsync<string>(
tenantId, "logo_url"); // Кастомний брендинг
var smtpSettings = await settingsService
.GetSettingAsync<SmtpSettings>(tenantId, "smtp"); // Свій email-сервер
var featureFlags = await settingsService
.GetSettingAsync<Dictionary<string, bool>>(
tenantId, "feature_flags"); // A/B тести, бета-фічі
var timezone = await settingsService.GetSettingAsync<string>(
tenantId, "timezone") ?? "UTC";
10. Query logging та аудит для multi-tenancy
public class TenantAuditMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantAuditMiddleware> _logger;
public TenantAuditMiddleware(
RequestDelegate next,
ILogger<TenantAuditMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(
HttpContext ctx,
ITenantProvider tenantProvider)
{
await _next(ctx);
// Логуємо кожен API запит з tenant контекстом
if (ctx.Request.Path.StartsWithSegments("/api"))
{
_logger.LogInformation(
"TenantAudit: Tenant={TenantId} User={UserId} " +
"Method={Method} Path={Path} Status={Status} " +
"Duration={Duration}ms IP={IP}",
tenantProvider.TenantId,
ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value,
ctx.Request.Method,
ctx.Request.Path,
ctx.Response.StatusCode,
/* duration calculation */0,
ctx.Connection.RemoteIpAddress);
}
}
}
11. Практичні завдання
Рівень 1: Базовий
ITenantEntityінтерфейс +TenantEntityбазовий класAppDbContext.OnModelCreating→HasQueryFilterдляProductтаOrderTenantProvider(Scoped) таTenantMiddlewareJwtClaimTenantResolver— читаєtenant_idз JWT- Тест: запит від tenant A не бачить дані tenant B
- Реалізуйте усі 3 resolver: JWT claim, Header
X-Tenant-Id, Subdomain CompositeTenantResolver→ пробуємо JWT → Header → Subdomain- Тест з curl: без JWT, лише заголовок
X-Tenant-Id: acme→ правильний tenant - Тест: поддомен
acme.localhost→ tenant = "acme" - Тест: невідомий tenant → 400 від TenantMiddleware
Рівень 2: Проєктування
- Налаштуйте PostgreSQL (Docker) та
TenantSchemaDbContextзHasDefaultSchema TenantMigrationService.MigrateTenantAsync— створення schema + міграціяPOST /admin/tenants— реєстрація нового tenant, автоматичне provisioning- Перевірте у psql:
\dn— бачите схеми для кожного tenant? SELECT * FROM acme."Products"таSELECT * FROM beta."Products"— дані ізольовані
TenantSettingsServiceз кешем (5 хвилин)GET /tenant/settings— повертає поточне налаштуванняPUT /tenant/settings/{key}— оновлення (для tenant admin)- Tenant A задає
max_products = 100, Tenant B →max_products = 10 - Implement business validation: якщо Product count >
max_products→ 422
Рівень 3: Архітектура
Побудуйте production-ready multi-tenant систему:
- Separate DB стратегія з template connection string + per-tenant override
- Автоматичний provisioning при реєстрації tenant (schema → migrate → seed)
- Tenant usage metrics:
GET /admin/tenants/{id}/metrics— кількість users, products, API calls за сьогодні - Tenant throttling:
max_api_calls_per_dayз per-tenant Rate Limiter - Data export:
GET /admin/tenants/{id}/export— повний дамп даних tenant (GDPR Right to Portability)
12. Резюме
Обери стратегію ізоляції
QueryFilter = перша лінія оборони
HasQueryFilter(e => e.TenantId == _tenant.TenantId) автоматично ізолює всі запити. Ніколи не забуте, якщо правильно налаштовано.ITenantProvider — ін'єктується скрізь
TenantProvider, наповнений TenantMiddleware на початку кожного запиту. JwtClaimTenantResolver + SubdomainTenantResolver + HeaderTenantResolver.Provisioning = повний lifecycle
TenantMigrationService.Завершено! Ви пройшли повний шлях від базових концепцій аутентифікації до advanced тем: OIDC, API Keys, Rate Limiting, Refresh Tokens, mTLS, моделі авторизації та Multi-tenancy.
RBAC, ABAC та ReBAC в ASP.NET Core
Три моделі контролю доступу: Role-Based (RBAC), Attribute-Based (ABAC) та Relationship-Based (ReBAC). Реалізація в ASP.NET Core через Policy, Requirements, IAuthorizationHandler та кастомні AuthorizationAttribute.
In-App нотифікації через базу даних
Вивчаємо основи системи нотифікацій: проєктуємо таблицю, реалізуємо CRUD-ендпоінти у ASP.NET Minimal API та будуємо першу повноцінну систему сповіщень на основі Pull Model.