Multi-tenancy: Aplikacje (Applications)
CRM jest multi-tenant — jedna instancja serwuje dziesiątki dealerów (Gezet, Tandem, …) i
importerów (Renault, Volkswagen, …). Każdy z nich może być wystawiony pod jednym lub wieloma
hostami (gezet.localhost, gezet-renault.localhost, renault.localhost, …). Każdy taki host
to osobna Application — instancja aplikacji CRM osiągalna pod konkretnym hostem, z własnym
brandingiem i własną logiką filtrowania danych.
Ten model jest specyficzny dla CRM (i będzie wzorcem dla BMS / Service), ale partnerska apka nie musi go używać. Jeśli jednak chcesz brand-scoped subdomeny + staff impersonation — to jest jak my to robimy.
Uwaga na nazewnictwo: “Application” w tym dokumencie oznacza naszą wewnętrzną instancję CRM pod konkretnym hostem (np.
gezet-renault.localhost→ Gezet w sieci Renault). To NIE jest to samo co “aplikacja partnerska” / “klient OAuth2” w sensie rejestracji w DealerID — patrz Rejestracja aplikacji.
Glosariusz
Dział zatytułowany „Glosariusz”| Termin | Definicja | Przykład |
|---|---|---|
| Application | Instancja aplikacji CRM osiągalna pod konkretnym hostem. Rekord w tabeli application. | gezet.localhost, renault.localhost, staff.localhost |
| Application kind | ENUM określający charakter Application: dealer_main, importer_main, importer_network, staff. | (patrz niżej) |
| Dealer main | Główna aplikacja dealera (jego “domowy” CRM). | gezet.localhost → Gezet |
| Importer main | Główna aplikacja importera (CRM importera). | renault.localhost → Renault Polska |
| Importer network | Aplikacja dealera w sieci konkretnego importera. Dziedziczy branding z importer’s organization. Pokazuje tylko salony dealera w tej sieci. | gezet-renault.localhost → Gezet w sieci Renault |
| Staff | Aplikacja staff DealerCRM (administracja platformy). Bypass filtra tenantu. | staff.localhost |
| Brand | Marka samochodowa (NIE to samo co importer — Stellantis to importer obejmujący marki Peugeot+Opel+Citroën). | Renault, Dacia, Honda |
| Importer | Importer/dystrybutor samochodów (= organization typu importer). | Renault Polska, Honda Motor Europe |
| Dealer | Dealer / grupa dealerska (= organization typu dealer). | Gezet Auto S.A., Tandem |
Model danych
Dział zatytułowany „Model danych”Tabela application
Dział zatytułowany „Tabela application”| Kolumna | Typ | Opis |
|---|---|---|
id | uuid | PK (UUID v7). |
host | varchar | Unique. Pełny host (z portem dla dev). Np. gezet.localhost:3001, gezet-renault.localhost:3001, staff.localhost:3001. |
organization_id | uuid | FK do organization — właściciel aplikacji. Dla dealer_main to organization dealera, dla importer_main to organization importera, dla staff to platform org. |
kind | ENUM | Jedna z: dealer_main, importer_main, importer_network, staff. Decyduje o logice brandingu i filtra SQL. |
importer_id | uuid? | FK do organization (organization typu importer). Nullable — wypełnione tylko dla kind = 'importer_network'. Wskazuje, w sieci którego importera Application działa. |
active | boolean | FALSE ⇒ host nie resolve’uje się (kill switch). |
created_at / updated_at | timestamp | Audit. |
Zarządzanie: CRUD po stronie staff panelu DealerCRM (/configuration → staff-only). Patrz
memorka project_organizations_staff_only.
Konwencja nazewnictwa hostów (flat namespace)
Dział zatytułowany „Konwencja nazewnictwa hostów (flat namespace)”Wszystkie hosty CRM siedzą w jednym, płaskim levelu subdomeny pod env zone
(<env>.dealercrm.app). Konkretnie:
| Wzorzec | Application kind | Przykład (QA) |
|---|---|---|
{dealer-slug}.<env>.dealercrm.app | dealer_main | gezet.qa.dealercrm.app |
{importer-slug}.<env>.dealercrm.app | importer_main | renault.qa.dealercrm.app |
{dealer-slug}-{importer-slug}.<env>.dealercrm.app | importer_network | gezet-renault.qa.dealercrm.app |
staff.<env>.dealercrm.app | staff | staff.qa.dealercrm.app |
Kluczowe: dealer-w-sieci-importera używa myślnika jako separatora, nie kropki.
To trzyma wszystkie tenanty pod jednym wildcard certem *.<env>.dealercrm.app i jedną
regułą ingress. Konsekwencja: org slugi nie mogą zawierać myślników (eliminuje
ambiguity gezet-renault vs hyphenated single slug). Walidator UI wymusza
[a-z0-9]{2,32}.
Uzasadnienie i rozważone alternatywy: ADR-0005 w docs/architecture/0005-tenant-subdomain-namespace.md (repo root, nie Starlight — ADR-y to wewnętrzny log decyzji architektonicznych).
Branding skin — jedno źródło per organization
Dział zatytułowany „Branding skin — jedno źródło per organization”Branding skin mieszka na organization.branding_skin_slug (jedno źródło prawdy per firma).
Application dziedziczy branding od organization:
| Application kind | Branding pochodzi z |
|---|---|
dealer_main | organization wskazanej przez application.organization_id |
importer_main | organization wskazanej przez application.organization_id |
importer_network | organization wskazanej przez application.importer_id (importer!) |
staff | organization wskazanej przez application.organization_id |
Dlaczego importer_network używa importera, nie dealera? Bo gezet-renault.localhost ma być
brandingowany jak Renault (yellow / black), nie jak Gezet. To jest punkt styku dealera z marką
importera — UI musi wyglądać “jak Renault”.
Routing — od request do application context
Dział zatytułowany „Routing — od request do application context”flowchart TB
REQ([HTTP request<br/>gezet-renault.localhost:3001])
subgraph BFF["crm-frontend (Next.js BFF)"]
Middleware["middleware.ts<br/>(forward Host as X-Forwarded-Host)"]
SessionFetch["/api/tenant/init<br/>(server-side)"]
end
subgraph API["crm-api"]
Resolve["GET /api/public/tenant/resolve<br/>(reads Host / X-Forwarded-Host)"]
DB[("application<br/>+ organization")]
Validator["ApplicationAccessValidator<br/>(priority 7)"]
Init["ApplicationContextInitializer<br/>(priority 5)"]
Filter["TenantScopeFilter<br/>(Doctrine SQL filter)"]
end
REQ --> Middleware
Middleware --> SessionFetch
SessionFetch -->|"X-Forwarded-Host: gezet-renault.localhost:3001"| Resolve
Resolve --> DB
DB -->|"Application{<br/>id, host, kind,<br/>organization_id,<br/>importer_id,<br/>brandingSkinSlug<br/>(inherited)}"| Resolve
Resolve -->|"applicationId<br/>brandingSkinSlug"| Middleware
Middleware -->|"forward Host w fetch do API"| API
API --> Validator
Validator -->|"sprawdź user.memberships<br/>vs application.organization_id"| Init
Init --> Filter
Filter -->|"WHERE per kind (patrz niżej)"| DB
Zmiana vs poprzedni model: BFF już nie wysyła headerów
X-Organization-IdaniX-Brand-Scope-Id. Backend wyłącznie resolve’uje aplikację zHostheader (forward przezX-Forwarded-Host). Cały context aplikacji (organization, importer scope, kind, branding) jest jednoznacznie wyznaczony przez host.
Resolve aplikacji — wyłącznie z Host header
Dział zatytułowany „Resolve aplikacji — wyłącznie z Host header”CRM API rozpoznaje Application wyłącznie z Host header (lub X-Forwarded-Host jeśli za
reverse proxy). Nie ma już żadnego klienckiego headera, który decyduje o tenant context — to
zapobiega class of bugs, w którym BFF i API mogły “rozjechać się” co do tego, której org dotyczy
request.
ApplicationAccessValidator:
- Z
Hostheader → SELECT zapplicationWHERE host = ? AND active = TRUE. - Jeśli brak rekordu →
404(host nie jest zarejestrowany). - Walidacja membership: user musi mieć membership w
application.organization_id. Wyjątki:- Staff (
is_internal_staff = TRUE) → bypass. - Application kind
staff→ wymagais_internal_staff = TRUE(inny user dostanie 403).
- Staff (
- Jeśli OK →
ApplicationContextInitializerhydujeRequestScopedApplicationContextz polami aplikacji i aktywuje Doctrine SQL filter.
SQL filter logic per kind
Dział zatytułowany „SQL filter logic per kind”Doctrine SQL filter (encje oznaczone atrybutem #[TenantScoped]) dostają WHERE w zależności od
application.kind:
| Kind | SQL filter dorzucany do query | Wyjaśnienie |
|---|---|---|
dealer_main | WHERE dealer_id = $org_id | Dealer widzi tylko swoje dane (lead, klient, szansa…). |
importer_main | WHERE importer_id = $org_id | Importer widzi tylko dane swojej sieci dealerskiej. |
importer_network | WHERE dealer_id = $org_id AND importer_id = $importer_id | Dealer w sieci importera widzi tylko swoje dane przefiltrowane przez sieć importera. |
staff | (bypass — brak filtra) | Staff widzi cross-tenant queries. UI musi go ostrzec, że nie ma filtrowania. |
Gdzie:
$org_id=application.organization_id$importer_id=application.importer_id(tylko dlaimporter_network)
Konwencja nazewnictwa kolumn
Dział zatytułowany „Konwencja nazewnictwa kolumn”Encje denormalizują FK organization na konkretne role:
{role}_id(np.dealer_id,importer_id) — FK doorganizationgdzie rola jest znana z kontekstu domeny. Np.lead.dealer_id,lead.importer_id.organization_id— FK doorganizationbez specyficznej roli (rzadkie, np. dla encji cross-org typu shared lookup).
To pozwala filtrowi SQL pisać precyzyjne WHERE bez JOIN’a do tabeli organization.
Staff bypass
Dział zatytułowany „Staff bypass”Gdy application.kind = 'staff', bypassFilter = true — Doctrine filter się nie aktywuje.
Staff widzi cross-tenant queries. UI musi:
- Pokazać czerwony banner “Tryb staff — widzisz wszystkie tenanty”.
- (TODO) Logować każdą operację write w audit logu.
Branding — przykłady
Dział zatytułowany „Branding — przykłady”gezet.localhost:3001 → Application(kind=dealer_main, organization_id=Gezet) → branding z Gezet.branding_skin_slug (np. "default")
gezet-renault.localhost:3001 → Application(kind=importer_network, organization_id=Gezet, importer_id=Renault) → branding z Renault.branding_skin_slug ("renault" — yellow/black) → SQL filter: WHERE dealer_id = Gezet.id AND importer_id = Renault.id
renault.localhost:3001 → Application(kind=importer_main, organization_id=Renault) → branding z Renault.branding_skin_slug ("renault") → SQL filter: WHERE importer_id = Renault.id
staff.localhost:3001 → Application(kind=staff, organization_id=PlatformOrg) → branding z PlatformOrg.branding_skin_slug (np. "staff") → bypass filtraUser Łukasz, który ma membership w Gezet i Tandem (oraz is_internal_staff = TRUE):
- Na DealerID
/account/appszobaczy karty:Gezet(gezet.localhost),Gezet — Renault(gezet-renault.localhost),Tandem(tandem.localhost),DealerCRM Staff(staff.localhost — bo kind=staff i Łukasz ma is_internal_staff). - Klika kartę → trafia na odpowiedni host → BFF forward’uje
Host→ CRM API rozpoznaje Application i aktywuje odpowiedni filtr.
Staff impersonation banner
Dział zatytułowany „Staff impersonation banner”Gdy staff klika kartę dealera (np. żeby zdebuggować problem klienta), wchodzi przez “normalny”
host typu gezet.localhost. Frontend wykrywa isStaff && currentApplication.kind !== 'staff' i
pokazuje czerwony banner „Pracujesz jako staff w Gezet — wszystkie zmiany zostaną zalogowane”.
(Status: zaimplementowane w fazie 1, audit logging jeszcze TODO.)
Alternatywnie staff zostaje na staff.localhost i operuje cross-tenant — wtedy kind = 'staff'
i bypass filtra jest aktywny od początku.
Niezmienniki
Dział zatytułowany „Niezmienniki”- Host → dokładnie jedna aktywna Application (
UNIQUE (host)przyactive = TRUE). application.organization_idzawsze wskazuje org “własną” Application (dealer dladealer_main/importer_network, importer dlaimporter_main, platform dlastaff).application.importer_idjestNOT NULLwyłącznie dlakind = 'importer_network'.- Branding skin dziedziczony z organization —
applicationnie ma własnegobranding_*pola. - Frontend BFF nigdy nie wysyła ani nie ufa headerom
X-Organization-Id/X-Brand-Scope-Id(te headery zostały usunięte z architektury). Application context wyznacza się wyłącznie z Host. - API nie ufa sam hostowi do autoryzacji — sprawdza, że user ma membership w
application.organization_id(lub jest staff).
Implementacja w nowej apce (BMS)
Dział zatytułowany „Implementacja w nowej apce (BMS)”Jeśli BMS ma być multi-tenant z hostami:
- Skopiuj wzorzec
application(lub uprość — może wystarczy bezimporter_network+ bezstaffkindu). - Zaimplementuj
/api/public/application/resolve(no-auth) — czytaHostheader. - Twój BFF forward’uje
Host/X-Forwarded-Hostdo API. Nie wymyślaj własnych headerów. - Twój
ApplicationAccessValidatorwaliduje membership wapplication.organization_id. - Pamiętaj o exempt-list
/api/dealerid(server-to-server discovery, bez application context). - (Opcjonalnie) Doctrine
TenantScoped+ SQL filter dla cross-tenant safety, z logiką per-kind jak wyżej.
Dla mniejszych apek (np. Portal Klienta jest single-tenant per user — klient widzi tylko swoje dane, organization ID ≡ jego dealerstwo) wystarczy uproszczona wersja: 1 user → 1 organization, host obowiązkowy, ale resolve trywialny i bez per-kind logiki.