Toate articolele
SaaS multi-tenant · 6 min citire ·

Izolarea datelor în SaaS multi-tenant cu EF Core global query filters

Cum construiești izolarea per tenant fără să repeți WHERE TenantId în fiecare interogare — și care sunt capcanele care produc scurgeri de date.

EF Coremulti-tenantSaaS.NETsecuritate

Într-un SaaS multi-tenant, cea mai periculoasă categorie de bug nu e cea care aruncă o excepție. E cea care returnează date. Datele altcuiva.

Riscul apare din repetiție: dacă izolarea depinde de faptul că fiecare dezvoltator își amintește să adauge WHERE TenantId = ..., atunci e o chestiune de timp până cineva uită. Global query filters din EF Core mută regula într-un singur loc, unde nu poate fi uitată.

Articolul ăsta e despre cum le folosești corect — și, mai important, despre ce nu acoperă.

Ce face de fapt un global query filter?

Declari o singură dată, la construirea modelului, o condiție care se adaugă automat la orice interogare LINQ pe acea entitate:

modelBuilder.Entity<TimeEntry>()
    .HasQueryFilter(e => e.TenantId == _tenantId);

De atunci, context.TimeEntries.ToListAsync() generează SQL cu filtrul inclus. La fel și navigările către entitatea respectivă. Efectul practic: izolarea devine implicită, nu o disciplină individuală.

Detaliul important e că _tenantId trebuie să fie un câmp al contextului, nu o variabilă locală capturată la configurare. EF construiește expresia o singură dată, dar citește câmpul la execuție — deci fiecare instanță de context aduce propriul tenant.

Cum obții tenant-ul curent fără să-l cari peste tot?

Tiparul care funcționează: un serviciu scoped care rezolvă tenant-ul din cerere (subdomeniu, claim din token, header) și e injectat în context.

public class AppDbContext : DbContext
{
    private readonly Guid _tenantId;

    public AppDbContext(DbContextOptions<AppDbContext> options, ITenantProvider tenant)
        : base(options) => _tenantId = tenant.TenantId;
}

Două reguli pe care le-am învățat pe pielea mea:

  • Tenant-ul vine din ceva ce utilizatorul nu poate alege liber. Un claim semnat în token, nu un header pe care îl poate schimba din DevTools.
  • Dacă tenant-ul lipsește, cererea eșuează. Niciodată „dacă nu știu tenant-ul, arăt tot“. Fail-closed, întotdeauna.

De ce filtrul nu te protejează la scriere?

Asta e capcana numărul unu, și e contraintuitivă: filtrele se aplică la interogări, nu la inserări.

Dacă cineva salvează o entitate fără TenantId setat, sau cu al altcuiva, EF o scrie fericit. Iar apoi acea înregistrare devine invizibilă pentru tenant-ul corect și vizibilă pentru cel greșit.

Soluția e să ștampilezi și să validezi în SaveChanges:

Două detalii decid dacă asta chiar funcționează.

Suprascrii metodele corecte. DbContext are patru puncte publice de salvare, dar cele fără parametri deleagă către acestea două. Dacă suprascrii doar SaveChangesAsync(CancellationToken), un SaveChanges() sincron trece pe lângă verificare:

// Ambele puncte publice de intrare trec prin aceste două supraîncărcări.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
    GuardTenant();
    return base.SaveChanges(acceptAllChangesOnSuccess);
}

public override Task<int> SaveChangesAsync(
    bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
    GuardTenant();
    return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}

Verifici valoarea originală, nu pe cea curentă. Asta e jumătatea subtilă:

private void GuardTenant()
{
    foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.TenantId = _tenantId;
            continue;
        }

        if (entry.State is not (EntityState.Modified or EntityState.Deleted)) continue;

        var tenant = entry.Property(e => e.TenantId);
        // Compararea valorii CURENTE ar lăsa asta să treacă: încarci rândul lui B,
        // pui TenantId = A, salvezi. Curent == al meu, deci ar trece.
        if (!Equals(tenant.OriginalValue, _tenantId) || tenant.IsModified)
            throw new InvalidOperationException("Cross-tenant write blocked.");
    }
}

Partea cu throw e cea care contează. Nu doar completezi tenant-ul lipsă — refuzi scrierea dacă cineva încearcă să modifice o entitate a altui tenant, sau să mute una de-a ta către alt tenant.

Care sunt capcanele care produc scurgeri?

Capcană De ce scapă Ce faci
Scrieri nefiltrate Filtrele se aplică doar la citire Ștampilare + validare în SaveChanges (mai sus)
IgnoreQueryFilters() Dezactivează dintr-o dată toate filtrele nedenumite Interzis în codul de aplicație; permis doar în joburi de sistem, revizuit explicit
ExecuteUpdate / ExecuteDelete Ocolesc change tracker-ul, deci verificarea din SaveChanges nu rulează Nu seta niciodată TenantId prin ele; revizuiește fiecare utilizare
ExecuteSql*, SqlQuery*, Dapper/ADO.NET Nu văd deloc modelul EF Scrii predicatul manual
Navigări obligatorii Un inner join către un principal filtrat poate face rândurile să dispară Fă navigările opționale sau filtrează consistent ambele capete
Căutare după cheie FindAsync poate returna o instanță deja urmărită, fără să reaplice filtrul Folosește FirstOrDefaultAsync pe entități cu tenant

Două clarificări care merită reținute, pentru că sfatul obișnuit e greșit la ambele:

FromSql* este filtrat. Spre deosebire de ExecuteSqlRaw sau Database.SqlQueryRaw<T>, un FromSql pe un DbSet se compune în pipeline-ul LINQ: EF îți împachetează instrucțiunea ca subinterogare și adaugă predicatul filtrului. Calea cu adevărat riscantă e cealaltă. Și ExecuteUpdate/ExecuteDelete respectă filtrul în WHERE — dar sar peste change tracker, deci nimic nu le oprește să scrie id-ul altui tenant în rândurile tale.

FindAsync e cea mai subtilă. Verifică întâi change tracker-ul; dacă entitatea e deja urmărită, o primești direct, fără reevaluarea filtrului. Ca să conteze, trebuie ca o entitate a altui tenant să fie deja urmărită — ceea ce se întâmplă prin IgnoreQueryFilters(), un Attach(), materializare din SQL brut, sau un context reutilizat între tenanți.

Ce despre soft-delete?

De regulă vrei ambele condiții: tenant și „neșters“. Atenție aici: două apeluri HasQueryFilter nedenumite nu se combină — al doilea îl înlocuiește tăcut pe primul. E valabil și în EF Core 10 și a produs destule scurgeri discrete. Combină-le explicit:

.HasQueryFilter(e => e.TenantId == _tenantId && !e.IsDeleted);

EF Core 10 a adăugat filtre denumite, care chiar se compun și îți permit să renunți selectiv la unul cu IgnoreQueryFilters(["SoftDelete"]), în loc să le dezactivezi pe toate. Merită știut: înseamnă că „arată-mi rândurile șterse“ nu mai trebuie să însemne „arată-mi rândurile șterse ale tuturor clienților“.

Cum testezi că izolarea chiar funcționează?

Aici e diferența dintre „cred că e izolat“ și „știu că e izolat“. Testul pe care îl scriu mereu:

  1. creezi două tenant-uri cu date;
  2. deschizi un context ca tenant A;
  3. verifici că nicio interogare nu returnează nimic al lui B;
  4. încerci să scrii o entitate cu TenantId al lui B și verifici că aruncă.

Iar pentru acoperire reală, un test care iterează toate entitățile cu tenant și verifică prezența filtrului — nu doar cele la care te-ai gândit. E genul de test care prinde entitatea nouă adăugată luna viitoare de altcineva.

Când nu e suficient?

Query filters îți dau izolare logică într-o bază de date comună. E o soluție foarte bună pentru majoritatea produselor, dar nu e singura — iar când cerințele cer o separare impusă de motor, nu de codul tău, discuția se mută la bază de date per tenant. Despre compromisurile alea am scris separat.

Dacă construiești un SaaS multi-tenant și vrei o a doua opinie pe modelul de izolare, e exact genul de problemă pe care am lucrat în mai multe produse.

Proiectul din spatele articolului Workly — Pontaj & Operațiuni Interne Pontaj, concedii, documente, tickets, assets și stocuri — multi-tenant, configurabil per firmă.

Ai un proiect similar?

Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.

Programează o discuție
Toate articolele