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.
De ce pooled (și nu DB/schema per tenant)
Secțiune intitulată „De ce pooled (și nu DB/schema per tenant)”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ă.
Coloana firm_id
Secțiune intitulată „Coloana firm_id”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, … })).
Rezolvarea tenantului per request
Secțiune intitulată „Rezolvarea tenantului per request”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):
- citește identitatea firmei din header
X-Firm-Id(dev / service-to-service) sau din claim-ulfirm_iddin JWT (canonical); - caută
TenantInfoprinITenantDirectory; - dacă firma există și e
Active, seteazăCurrentTenantAccessorpe 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_idDefense-in-depth: două straturi
Secțiune intitulată „Defense-in-depth: două straturi”| Strat | Mecanism | Stare |
|---|---|---|
| 1. Filtru explicit în cod | Fiecare query filtrează pe firm_id (.Where(x => x.FirmId == …)) | Implementat |
| 2. RLS în Postgres | Policy pe firm_id folosind GUC-ul de sesiune app.firm_id | Planificat |
Un singur strat = inacceptabil pentru un SaaS B2B. Stratul 2 este backstop-ul: dacă programatorul
uită un WHERE, policy-ul RLS refuză rândurile.
RLS cu GUC app.firm_id
Secțiune intitulată „RLS cu GUC app.firm_id”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.
Câmpuri personalizate per tenant
Secțiune intitulată „Câmpuri personalizate per tenant”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.
Particularitate: IDNO nu e unic
Secțiune intitulată „Particularitate: IDNO nu e unic”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).
Legături
Secțiune intitulată „Legături”- Unde se așază tenancy în straturi: Clean Architecture + module.
- Cum poartă evenimentele
firm_idca cheie de partiție: Flux event-driven. - Cum se leagă autentificarea de tenant: Autentificare și autorizare.