Auth

Multi-tenancy та ізоляція даних в ASP.NET Core

Три стратегії multi-tenancy: спільна БД з discriminator, окремі схеми та окремі БД. ITenantProvider, EF Core QueryFilters, Tenant resolver через Claims/Header/Subdomain, ізоляція конфігурацій та управління tenant-специфічними налаштуваннями.

Multi-tenancy та ізоляція даних в ASP.NET Core

Multi-tenancy — архітектура, де один застосунок обслуговує кількох незалежних клієнтів (tenants). Кожен tenant думає, що система — лише для нього. Ізоляція даних — ключова вимога: дані tenant A ніколи не повинні бути видимі для tenant B. Якщо хоч один запит «витече» між tenants — це може бути катастрофою.

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 + ColumnShared DB + SchemaSeparate DB
Ізоляція⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Вартість💚 Низька💛 Середня🔴 Висока
Compliance⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Складність💚 Низька💛 Середня🔴 Висока
Scale-out💚 Просто💛 Середньо🔴 Складно

2. ITenantProvider: інтерфейс ізоляції

Ключовий компонент — сервіс, що визначає поточний tenant із запиту:

Interfaces/ITenantProvider.cs
public interface ITenantProvider
{
    string? TenantId { get; }
    bool    IsValid  { get; }
}

public interface ITenantResolver
{
    Task<string?> ResolveAsync(HttpContext context);
}

Модель Tenant

Models/Tenant.cs
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)

Security/SubdomainTenantResolver.cs
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)

Security/JwtClaimTenantResolver.cs
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);
    }
}
При генерації JWT — додаємо tenant_id claim
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

Security/HeaderTenantResolver.cs
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 (пробуємо кілька методів)

Security/CompositeTenantResolver.cs
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 у контекст

Middleware/TenantMiddleware.cs
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);
    }
}
Services/TenantProvider.cs — Scoped provider
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

Data/AppDbContext.cs — Shared DB з QueryFilter
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 сутностей

Interfaces/ITenantEntity.cs
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-операцій!

Безпечне та небезпечне використання IgnoreQueryFilters
// ✅ Безпечно: 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)

Data/TenantSchemaDbContext.cs — Schema per tenant
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

Services/TenantMigrationService.cs
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

Services/TenantConnectionResolver.cs
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);
    }
}
Data/MultiDbContextFactory.cs — динамічний DbContext
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

POST /admin/tenants — реєстрація нового tenant
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-специфічна конфігурація

Services/TenantSettingsService.cs
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-специфічні налаштування

Приклади налаштувань 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

Middleware/TenantAuditMiddleware.cs
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: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


12. Резюме

Обери стратегію ізоляції

Shared DB → просто та дешево (startup). Schema → баланс. Separate DB → enterprise, compliance. Правильний вибір залежить від вимог GDPR, data residency та бюджету.

QueryFilter = перша лінія оборони

EF Core HasQueryFilter(e => e.TenantId == _tenant.TenantId) автоматично ізолює всі запити. Ніколи не забуте, якщо правильно налаштовано.

ITenantProvider — ін'єктується скрізь

Scoped TenantProvider, наповнений TenantMiddleware на початку кожного запиту. JwtClaimTenantResolver + SubdomainTenantResolver + HeaderTenantResolver.

Provisioning = повний lifecycle

Реєстрація tenant → створення schema/БД → міграція → seed → перший логін. Автоматизація через TenantMigrationService.

Завершено! Ви пройшли повний шлях від базових концепцій аутентифікації до advanced тем: OIDC, API Keys, Rate Limiting, Refresh Tokens, mTLS, моделі авторизації та Multi-tenancy.

Copyright © 2026