← bütün yazılar

Nº02 // YAZILAR

Multi-tenant tamam başqa oyundur

İllərlə bir sayt üçün proqram yazdım. Bir quraşdırma, bir admin, bir dəst parametr, bir baza — və bu bazada hər sətir gizli şəkildə mənim idi. Sonra Booknetic SaaS üzərində kommersiya addonları yazmağa başladım — orada bir quraşdırma yüzlərlə ayrı biznesə xidmət edir, hər biri bir tenant-dır və heç vaxt digərlərini görmür — və demək olar ki, bütün refleksimin səssizcə yanlış olduğu üzə çıxdı.

Bu, platformanın özü haqqında dərslik deyil. SaaS qatını Booknetic verir: tenant modelini, imkanlar (capability) sistemini, həyat dövrü (lifecycle) hook-larını. Mən isə fikir dəyişikliyindən yazmaq istəyirəm — multi-tenancy-nin məni yenidən öyrənməyə məcbur etdiyi üç fərziyyədən, tamamilə öz kodum üzərindən.

Birinci fərziyyə: hər funksiya sadəcə işləkdir

Bir saytda funksiya ya var, ya yox. Kodu yayımlayırsan, kod işləyir. SaaS-da isə funksiya tenant-ın haqqı olduğu bir şeydir — adətən onun ödədiyi plana bağlıdır. Pulsuz tenant və agentlik tenant-ı tam eyni kodu işlədir, amma tam eyni səlahiyyətləri almamalıdırlar.

Booknetic-in SaaS qatı məhz bunun üçün imkanlar sistemi təqdim edir. Mənim CNAME addonum — hər tenant-a öz domenini öz rezervasiya səhifəsinə yönəltməyə imkan verən addon — platformanın sonra planlara bağladığı bir capability qeydiyyatdan keçirir. Konkret olaraq, fərdi (custom) domenlər cname_custom_domain capability-si arxasında bağlanır və bunu Booknetic-in API-si vasitəsilə qeydiyyatdan keçirirəm; subdomenlər pulsuzdur, amma öz domenini gətir ödənişli səviyyədir. Plan–capability uyğunlaşmasının sahibi platformadır. Mən sadəcə capability-ni elan edirəm, sonra tenant-ın custom domen əlavə edə biləcəyi hər giriş nöqtəsində soruşuram: bu tenant-ın buna icazəsi var?

Ən uzun çəkən dərs bu oldu: bu gate (yoxlama) tək bir yoxlama deyil. Bu, hər səthdə bir yoxlamadır. Tenant panelindəki düymə, arxasındakı AJAX handler, validasiya, sonradan həmin sətir üzərində iş görən cron job. UI-da gizlədilmiş düymə təhlükəsizlik sərhədi deyil — istənilən adam sorğunu təkrar göndərə bilər. Ona görə də capability yoxlaması çəkim (paint) yolunda yox, yazma (write) yolunda dayanır. Bir saytda heç vaxt belə düşünmürdüm, çünki yoxlanılacaq heç kim yox idi.

İkinci fərziyyə: datam yalnız mənimdir

Bu, icazə versən, prodda səni dişləyəcək olandır. Bir saytda SELECT * FROM my_table tam və düzgün sorğudur. SaaS-da isə eyni sorğu data sızmasıdır — bütün tenant-ların sətirlərini birdən qaytarır.

Booknetic bunu öz ORM modellərindəki multi-tenant trait ilə həll edir. Mənim domen modelim bir class-ın ola biləcəyi qədər kiçikdir:

class CnameDomain extends Model
{
    use MultiTenant;

    protected static $tableName = 'cname_domains';
    protected static bool $timeStamps = true;
}

Bütün məsələnin mahiyyəti həmin bir use MultiTenant; sətrindədir. Platformanın trait-i qlobal bir scope əlavə edir ki, tenant-ın sorğusunun içində CnameDomain::where(...) avtomatik olaraq həmin tenant ilə məhdudlaşsın. Tenant A model vasitəsilə Tenant B-nin domenlərini sözün əsl mənasında sorğulaya bilməz — scope sorğunu işləməzdən əvvəl yenidən yazır. Bunu həqiqətən anlayanda, bir saytdan qalma paranoya ilə hər yerə səpələdiyim əllə yazılmış where('tenant_id', $id) şərtlərini topladım və sildim. Platforma onsuz da bunu edirdi, özü də məndən daha etibarlı şəkildə.

Amma scope yalnız oxumaqla bağlı deyil. O, bir tenant boyu uzanan invariantlar haqqında düşüncəni dəyişir. Addonumun bir qaydası var: tenant-ın bir neçə domeni ola bilər, amma yalnız biri əsas (primary) olur — hamının yönəldildiyi kanonik URL. “Tam bir dənə” qaydasını tətbiq etmək tenant səviyyəsində əməliyyatdır, ona görə də onu saf funksiya kimi saxladım: hədəfi və tenant-ın bütün domen id-lərini alır, hər biri üçün yeni bayrağı qaytarır:

public function plan(int $targetId, array $tenantDomainIds): array
{
    if (! in_array($targetId, $tenantDomainIds, true)) {
        throw new \InvalidArgumentException('Target domain does not belong to the tenant.');
    }
    $map = [];
    foreach ($tenantDomainIds as $id) {
        $map[$id] = ($id === $targetId) ? 1 : 0;
    }
    return $map;
}

Qoruyucuya diqqət et: tenant-a aid olmayan bir domeni primary etməyə çalışmaq exception atır. Bir saytda bu yoxlama gülüncdür — əlbəttə ki, sənindir. Multi-tenant dünyada isə bu, funksiya ilə zəiflik (exploit) arasındakı fərqdir.

Tenant üçün brendləşmə: hər kəsə fərqli bir qapı

Multi-tenancy-nin son müştəriyə ən görünən hissəsi odur ki, hər tenant öz ünvanını alır. SaaS adətən hazır halda yol (path) üzrə marşrutlaşdırır — example.com/site-a, example.com/site-b. Mənim CNAME addonum hər tenant-a ya subdomen (site-a.example.com), ya da DNS doğrulaması ilə tam fərdi domen (book.site-a.com) verir.

Əgər saf saxlasan, marşrutlaşdırma çox səliqəli, özünə-qapalı bir məsələyə çevrilir. Resolver gələn host və baza domendən başqa heç nə almır, doğrulanmış sətirlərə baxır və bir hökm qaytarır — keç (passthrough), yönləndir, 404, ya da bu, N tenant-ıdır:

$row = $this->lookup->findVerified($candidate);
// ...
return ResolutionResult::tenant((int) $row['tenant_id']);

“Tenant üçün brendləşmə”nin bütün mahiyyəti həmin sonuncu sətirdədir: simdən gələn ixtiyari bir hostname bir tenant id-yə uyğunlaşdırılır və o andan sorğu onlara aiddir. Əlbəttə, custom domen əvvəlcə sübut olunmalıdır — tenant özünə aid olmayan bir domeni öz adına iddia edə bilməz. Ona görə də DNS TXT doğrulama mərhələsi var: platforma üslubunda axın bir token verir, tenant booknetic-verify=<token> TXT yazısı əlavə edir, və bir state machine yalnız yazı həqiqətən həll olunanda sətri gözləmədən (pending) doğrulanmışa (verified) keçirir. O vaxta qədər host heç yerə marşrutlaşmır. SaaS-da brendləşmə heç vaxt “domeni yaz, qurtardı” deyil — “sahib olduğunu sübut et, sonra biz ona güvənərik”dir.

Yeni nasazlıq forması: cari tenant olmayanda

Budur, bir saytda sadəcə mövcud olmayan və indi ən çox hörmət etdiyim xəta sinfi.

Çox vaxt kodun tenant-ın sorğusunun içində işləyir, ona görə də “cari tenant” platformanın sənin üçün izlədiyi yaxşı müəyyən edilmiş bir şeydir. Amma həmişə yox. Backend-dən tenant yaradan super-admin. Qeydiyyat axını. Hər tenant-ı bir-bir gəzən cron job. CLI tapşırığı. Bütün bu kontekstlərdə cari tenant yoxdur — və əgər yuxarıda tərif etdiyim rahat avtomatik scope-a güvənsən, geri dişləyir. Avtomatik scope yazı üçün cari tenant-ı təxmin etmək üzərində qurulub, bu kontekstlərdə isə cari tenant null-dur.

Mən məhz burada yandım və həll yolu bir yazının kimə aid olduğunu açıq bildirməkdir. Addonum platformanın tenant-yaradıldı lifecycle hook-una cavab olaraq tenant-ın subdomenini avtomatik yaradanda — bu hook admin/qeydiyyat kontekstində işə düşür — avtomatik scope-dan imtina edib tenant id-ni özüm ötürürəm:

CnameDomain::noTenant()->insert([
    'tenant_id'           => $tenantId,
    'hostname'            => $hostname,
    'type'                => DomainType::SUBDOMAIN,
    'verification_status' => VerificationStatus::VERIFIED,
    // ...
]);

noTenant() çağırışı ORM-ə deyir: tenant-ı cari sorğudan təxmin etməyə çalışma — onun hansı olduğunu mən sənə deyirəm. Scope-un “cari” tenant üzərində qurulduğunu bir dəfə qəbul edəndə, cari tenant-ı olmayan bir kontekstdən gələn yazının niyə öz tenant-ını açıq adlandırmalı olduğu aydınlaşır — yoxsa düzgün yazacaq heç nəyi olmur. Eyni intizam təmizləmədə də keçərlidir: tenant silinəndə onun sətirlərini açıq id ilə silirəm, yenə sorğu scope-undan kənara çıxaraq, çünki silmə işinin də “cari” tenant-ı yoxdur.

Dərkin forması belədir: bir sayt kodunda “bu data kimindir?” sualının cavabı həmişə “saytın”dır, ona görə də heç vaxt soruşmursan. Multi-tenant kodda isə bu sualın hər yazıda cavabı olmalıdır və təhlükəli anlar məhz framework-ün sənin əvəzinə cavab verə bilmədiyi anlardır. Oxumalar özlərini scope edir. Kənarlardan — admin, cron, qeydiyyat — gələn yazılar isə öz tenant-ını ucadan adlandırmalıdır.

Başımda əslində nə dəyişdi

Üç vərdiş, hamısı getdi. Cari istifadəçinin olduğunu fərz etməyi dayandırdım. Sətirlərimin yalnız mənim olduğunu fərz etməyi dayandırdım. Bir giriş qapısının olduğunu fərz etməyi dayandırdım. Onların yerini hər funksiyaya bir sətir yazmazdan əvvəl indi verdiyim tək bir sual tutdu: hansı tenant üçün və onun icazəsi var? Bir saytda bu sual səs-küydür. SaaS-da isə işin özüdür — və düzünü desəm, bu məhdudiyyətin içində iş görmək məni bir saytlarda da daha iti mühəndis etdi.

Bu düşüncədən doğan addonların əksəriyyəti code-heaven.com/v/corelabs ünvanında bitdi (addons.itahir.com oraya yönləndirir). CNAME-li hələ də ən qürur duyduğumdur, məhz ona görə ki, çətin hissələrinin demək olar ki, heç biri ünvan sətrində sadəcə öz domenini görən tenant-a görünmür.