Sari la conținut

Multi-tenancy și RLS

ASYDATA folosește un model pooled multi-tenant: o singură bază Postgres, partajată de toate firmele de contabilitate. Tenantul este firma de contabilitate (nu compania individuală), iar izolarea datelor se face pe coloana firm_id, întărită prin defense-in-depth: filtru explicit în cod plus Row-Level Security în baza de date.

Decizia (asydata-app-backend-dotnet/docs/DECISIONS.md) respinge explicit schema-per-tenant și DB-per-tenant: sunt scumpe de operat la scară, iar izolarea prin RLS este suficientă pentru un SaaS B2B. EF Core se conectează prin AddPooledDbContextFactory<T> (pooling de conexiuni pentru performanță), iar tabelele Identity și outbox_events stau în aceeași bază.

Toate entitățile multi-tenant poartă coloana firm_id (uneori OwnerFirmId pentru resursele izolate per firmă). Indexurile compozite încep cu firm_id pentru a păstra interogările eficiente per tenant — se vede în maparea EF Core din asydata-app-backend-dotnet/src/Asy.Infrastructure/Persistence/AppDbContext.cs (zeci de HasIndex(x => new { x.FirmId, … })).

Tenantul se rezolvă în TenantContextMiddleware (asydata-app-backend-dotnet/src/Asy.Platform/Tenancy/TenantContextMiddleware.cs), care rulează înainte de UseAuthorization (ca filtrele RequireModule/RequirePermission să vadă tenantul):

  1. citește identitatea firmei din header X-Firm-Id (dev / service-to-service) sau din claim-ul firm_id din JWT (canonical);
  2. caută TenantInfo prin ITenantDirectory;
  3. dacă firma există și e Active, setează CurrentTenantAccessor pe scope-ul requestului.

Serviciile tenant-scoped citesc apoi ICurrentTenant (asydata-app-backend-dotnet/src/Asy.Application/Abstractions/Tenancy/ICurrentTenant.cs), care expune Id, ConnectionString și IsResolved.

Request ─► TenantContextMiddleware ─► CurrentTenantAccessor.Set(info)
│
servicii (ICurrentTenant) ◄───┘ → filtrează pe firm_id
StratMecanismStare
1. Filtru explicit în codFiecare query filtrează pe firm_id (.Where(x => x.FirmId == …))Implementat
2. RLS în PostgresPolicy pe firm_id folosind GUC-ul de sesiune app.firm_idPlanificat

Un singur strat = inacceptabil pentru un SaaS B2B. Stratul 2 este backstop-ul: dacă programatorul uită un WHERE, policy-ul RLS refuză rândurile.

Modelul-țintă (CLAUDE.md): înainte de orice query, requestul deschide o tranzacție și setează GUC-ul app.firm_id local tranzacției:

SELECT set_config('app.firm_id', '<firm-uuid>', true); -- true = is_local
-- policy RLS:
USING (firm_id = current_setting('app.firm_id', true)::uuid)

Crucial pentru pooling: GUC-ul trebuie să fie local tranzacției (is_local = true). Pool-ul Npgsql refolosește conexiuni fizice între requesturi; un GUC la nivel de sesiune s-ar „scurge” de la un tenant la altul. Local tranzacției, valoarea dispare la commit/rollback.

Fiecare firmă își poate extinde entitățile fără DDL: definiții în field_definitions + coloane custom_fields jsonb pe entitățile core. EF Core are nevoie de EnableDynamicJson() pe sursa de date Npgsql pentru a serializa Dictionary<string, object?> în jsonb (vezi asydata-app-backend-dotnet/src/Asy.Infrastructure/Auth/AuthExtensions.cs). Detalii în Clean Architecture + module.

Codul fiscal IDNO nu este unic și nu e validat la nivel de DB: orice utilizator poate înregistra o companie cu un IDNO inventat, iar validarea se face manual ulterior (admin). De aceea nu există constrângere UNIQUE pe idno — vezi CLAUDE.md și docs/DECISIONS.md. Aceeași regulă s-a aplicat și la nivel de parteneri (migrarea DropPartnerMasterIdnoUniqueAddOwnerFirmIndex).