İçeriğe atla

TypeScript'te Multi-Tenant Yetkilendirme: CASL vs Özel ABAC

İzin sisteminize multi-tenant izolasyonu ekleyin, CASL'ı kütüphane alternatifi olarak değerlendirin ve doğru yetkilendirme mimarisini karar çerçeveleriyle seçin.

Ayhan Sipahi Ayhan Sipahi

Post 101 bir izin sistemi için yedi hedef belirledi ve dağınık kontrol anti-pattern’ini ortaya koydu. Post 102 yetkilendirmeyi bir service layer içinde merkezileştirdi. Post 103 type-safe RBAC ekledi. Post 104 rol-izin matrisini ABAC policy engine ile değiştirdi. Post 105 ABAC’ı çevre koşulları, alan düzeyinde okuma/yazma izinleri ve veritabanı sorgu filtreleme ile genişletti.

Üretime çıkmadan önce üç sorun kalıyor. Sistemin tenant sınırı yok: Organizasyon A’daki bir kullanıcı, sorgu doğru kurgulanırsa Organizasyon B’nin kaynaklarına erişebilir; bu izolasyon her service metoduna değil, izin katmanına global bir koşul olarak yerleşmeli. Özel ABAC motoru çalışıyor ama ekibin bu kodu sürdürmeye devam mı edeceği yoksa CASL gibi bir kütüphaneye mi geçeceği belirsiz; motor, ekip bu kodu sahiplenmekten vazgeçene kadar yerinde kalır ve CASL’ın yerini kazandığı nokta tam olarak burasıdır. RBAC, özel ABAC, kütüphane tabanlı ABAC ve harici policy engine arasında seçim yapmayı sağlayan bir karar çerçevesi de hâlâ yok; bu seçim, her kaynağın kaç bağlamsal koşula ihtiyaç duyduğuna göre belirlenir.

Multi-Tenancy Modelleri#

Tenant Kavramı#

SaaS’ta bir tenant, kullanıcıları ve kaynaklarını gruplayan bir organizasyon, çalışma alanı veya hesaptır. Slack çalışma alanları, GitHub organizasyonları ve Notion çalışma alanları birer tenant’tır. Tenant sınırı, en dıştaki izin sınırıdır. Sistem; rolleri, sahipliği veya alan erişimini kontrol etmeden önce kullanıcının, kaynağın sahibi olan tenant’a ait olduğunu doğrulamalıdır.

Domain Modelini Genişletme#

Serinin domain modeli bir tenant boyutu kazanıyor:

interface User {
  userId: string;
  role: Role;
  departmentId?: string;
  tenantId: string;  // kullanıcının ait olduğu organizasyon
  tenantRole?: TenantRole; // tenant içindeki rol (owner, admin, member)
}

interface Document {
  id: string;
  title: string;
  content: string;
  authorId: string;
  status: 'draft' | 'published' | 'archived';
  projectId: string;
  departmentId: string;
  tenantId: string;  // bu dokümanın sahibi olan tenant
}

interface Project {
  id: string;
  name: string;
  ownerId: string;
  departmentId: string;
  tenantId: string;  // bu projenin sahibi olan tenant
}

type TenantRole = 'owner' | 'admin' | 'member';

Her kaynak artık bir tenantId taşıyor. Her kullanıcı tam olarak bir tenant’a ait. Birden fazla tenant’a üyelik mümkün ama bu kapsamın dışına taşan bir karmaşıklık getiriyor.

Üç İzolasyon Stratejisi#

Veritabanı Düzeyi İzolasyon

DB: tenant_a

DB: tenant_b

Şema Düzeyi İzolasyon

Tek Veritabanı

Şema: tenant_a

Şema: tenant_b

Satır Düzeyi İzolasyon

Tek Veritabanı

Tek Şema

Tenant A satırları

Tenant B satırları

Satır Düzeyi İzolasyon (paylaşılan şema): Tüm tenant’lar aynı tabloları paylaşır. Her tabloda bir tenant_id sütunu bulunur. En basit altyapı ve en ucuz seçenek, ancak bir WHERE tenant_id = ? koşulunu unutmak çapraz tenant veri sızıntısına neden olur. PostgreSQL Row-Level Security (RLS), veritabanı düzeyinde bir güvenlik ağı olarak bunu zorunlu kılabilir.

Şema Düzeyi İzolasyon: Her tenant aynı veritabanı içinde ayrı bir şema alır. Daha güçlü izolasyon: eksik WHERE koşulu veri sızıntısı yerine hata üretir. Migration’lar N şema üzerinde çalıştırılmalıdır. Onlarca ila yüzlerce tenant için uygundur.

Veritabanı Düzeyi İzolasyon: Her tenant özel bir veritabanı örneği alır. Maksimum izolasyon ve en güçlü uyumluluk duruşu. En yüksek maliyet ve operasyonel karmaşıklık.

BoyutSatır DüzeyiŞema DüzeyiVeritabanı Düzeyi
Altyapı maliyetiDüşükOrtaYüksek
İzolasyon gücüUygulama düzeyindeDB-şema düzeyindeFiziksel izolasyon
Tenant sayısı ölçeklenebilirliğiBinlerce+YüzlerceOnlarca
Migration karmaşıklığıTek migrationN migrationN migration + N veritabanı
Çapraz tenant sorgu riskiYüksek (eksik WHERE)Düşük (yanlış şema = hata)Yok
Tenant başına özelleştirmeSınırlıOrtaTam
Uyumluluk uygunluğuStandartSOC 2 / ISOHIPAA / PCI-DSS

Bu seri satır düzeyi izolasyon modelini kullanıyor çünkü en yaygın başlangıç noktası ve izin perspektifinden en zorlu olanı. Şema ve veritabanı izolasyonu tenant sınırlarını altyapı düzeyinde çözer. Satır düzeyi izolasyon, bu sınırları uygulamanın zorunlu kılmasını gerektirir.

Tenant Sınırını Bilen İzin Katmanı#

Dağınık Tenant Kontrolü Anti-Pattern’i#

İzin katmanı tenant sınırını bilmediğinde, her service metodu tenant izolasyonunu elle kontrol eder:

// Anti-pattern: her service metodunda manuel tenant kontrolü
async function getDocumentById(documentId: string) {
  const session = await requireSession();
  const document = await db.document.findUnique({ where: { id: documentId } });

  // Manuel tenant kontrolü, unutulması kolay
  if (document.tenantId !== session.tenantId) {
    throw new ForbiddenError();
  }

  // Sonra normal ABAC kontrolü
  if (!can(session, 'read', 'document', document)) {
    throw new ForbiddenError();
  }

  return filterFields(session, 'document', document);
}

Bu, Post 101’deki dağınık kontrol pattern’inin tenant düzeyinde yeniden ortaya çıkmasıdır. Tek bir endpoint’te tenant kontrolünün unutulması çapraz tenant veri sızıntısı yaratır; bu, diğer müşterilerin verilerini açığa çıkardığı için en tehlikeli yetkilendirme hatası sınıfıdır.

Tenant İzolasyonunu Global ABAC Koşulu Olarak Tanımlama#

Doğru yaklaşım: tenant izolasyonu, her izin kontrolünde otomatik olarak çalışan yerleşik bir koşul haline gelir:

// Tenant izolasyonunu yerleşik global koşul olarak tanımlama
const permissions = new PermissionBuilder()
  // Global koşul: TÜM roller, TÜM kaynaklar için geçerli
  .global((user, data) => {
    // Her kaynağın tenantId'si kullanıcının tenantId'si ile eşleşmeli
    if ('tenantId' in data && user.tenantId !== data.tenantId) {
      return false; // Çapraz tenant erişimi: REDDET
    }
    return true; // Aynı tenant: role-specific kontrollere devam et
  })

  .role('admin')
    .can('manage', 'document')
    .can('manage', 'project')

  .role('editor')
    .can(['read', 'update'], 'document', [
      (user, doc) => user.departmentId === doc.departmentId,
    ])

  // ... Post 104-105'ten kalan politikalar
  .build();

global() koşulu herhangi bir role-specific koşuldan önce çalışır. Her izin kontrolünde örtük bir WHERE koşulu gibi davranır. Bir geliştirici yeni bir rol veya yeni bir kaynak türü oluştursa bile, tenant izolasyonu otomatik olarak zorunlu kılınır.

can() Fonksiyonunu Güncelleme#

can() fonksiyonu önce global koşulları değerlendirir:

function can<R extends Resource>(
  user: User,
  action: Action,
  resource: R,
  data?: ResourceDataMap[R],
  env?: Environment
): boolean {
  // Adım 1: Global koşulları değerlendir (tenant izolasyonu)
  if (data) {
    for (const globalCondition of permissions.globalConditions) {
      if (!globalCondition(user, data as Record<string, unknown>)) {
        return false; // Global koşul başarısız (ör. yanlış tenant)
      }
    }
  }

  // Adım 2: Rol + kaynak + aksiyon için eşleşen girdileri bul
  // (Post 104-105 ile aynı mantık)
  const entries = permissions[user.role] as PermissionEntry<R>[];
  for (const entry of entries) {
    if (entry.resource !== resource) continue;
    if (!entry.actions.includes(action)) continue;

    if (!entry.conditions || entry.conditions.length === 0) return true;
    if (data) {
      const allMet = entry.conditions.every(c => c.evaluate(user, data, env));
      if (allMet) return true;
    }
  }

  return false; // Varsayılan olarak reddet
}

Veritabanı Sorgu Filtrelemesini Güncelleme#

Post 105’teki toWhereClause() fonksiyonu tenant filtreleme içermelidir:

function toWhereClause<R extends Resource>(
  user: User,
  resource: R,
  action: Action,
  env?: Environment
): WhereClause<R> | null {
  // Her zaman tenant filtresini ekle
  const tenantFilter = { tenantId: user.tenantId };

  const roleFilter = buildRoleFilter(user, resource, action, env);
  if (roleFilter === null) return null; // Erişim yok

  // Tenant filtresini role-specific filtre ile birleştir
  return { ...tenantFilter, ...roleFilter };
}

Tenant filtresi her zaman mevcuttur. buildRoleFilter() {} döndürse bile (admin için ek filtre yok), sorgu yine de WHERE tenantId = ? içerir.

Çapraz Tenant Erişim İstisnaları#

Bazı senaryolar çapraz tenant erişimi gerektirir:

  • Platform yöneticileri (süper adminler) tüm tenant’ları yöneten
  • Paylaşılan kaynaklar (şablonlar, herkese açık içerik) herhangi bir tenant’ın dışında var olan
  • Destek araçları müşteri hizmetlerinin tenant verilerini görüntülemesi için
// Platform yöneticisi tenant izolasyonunu atlar
.role('platform_admin')
  .global(() => true) // Global tenant kontrolünü geçersiz kıl
  .can('manage', 'document')
  .can('manage', 'project')
  .can('manage', 'tenant')

// Paylaşılan kaynaklarda tenantId yok
interface SharedTemplate {
  id: string;
  title: string;
  // tenantId yok, tüm tenant'lar erişebilir
}

Warning

Çapraz tenant istisnaları açık ve denetlenebilir olmalıdır. Platform yöneticisi rolü, tenant düzeyindeki yöneticiden ayrı olmalı ve Post 105’teki çevre koşulları ile zorunlu kılınan ek kimlik doğrulama gereksinimleri (MFA, IP kısıtlamaları) içermelidir.

Kütüphane Kullanım Gerekçesi#

Kendin Yazmak mı, Kütüphane mi?#

Post 101-105, RBAC, ABAC, alan düzeyinde izinler, DB sorgu filtreleme, çevre kuralları ve şimdi multi-tenancy’yi kapsayan özel bir izin sistemi oluşturdu. Bu yaklaşık 300-500 satır çekirdek izin mantığıdır. Bu kodu sürdürmek ne noktada bir kütüphane benimsemekten daha pahalı hale gelir?

Her İki Seçeneğin Bedeli#

Kendi kodunu yazmak, kritik güvenlik yolunda sıfır bağımlılık, can() imzası üzerinde tam kontrol ve domain’e özel tasarlanmış bir TypeScript entegrasyonu (generic constraint’ler, builder pattern’ler, type inference) sağlar. Hiçbir şey serileştirilmez: düz fonksiyonlar, class instance’ı yok, doğası gereği RSC uyumlu. Her koşul ekibin yazdığı bir fonksiyon olduğu için ortada kara kutu yoktur ve hata ayıklama normal bir çağrı yığınını takip eder. Ama bedeli de aynı ekibin üzerindedir: her hatayı, uç durumu ve güvenlik yamasını kendisi üstlenir, yaygın kullanılan bir kütüphanenin biriktirdiği topluluk testinden yoksun kalır; alan izinleri, DB sorgu dönüşümü ve koşul operatörleri ($in, $ne, $gte) sıfırdan yeniden yazılır ve yeni ekip üyeleri belgelenmiş bir API yerine özel bir API öğrenir.

Bir izin kütüphanesi, binlerce projede topluluk tarafından test edilmiş halde gelir; alan izinleri, MongoDB tarzı koşullar ve Prisma/Mongoose adaptörleri zaten hazırdır, dokümantasyon, Stack Overflow yanıtları ve konferans sunumları mevcuttur çünkü ekibin bunları kendisi yazması gerekmemiştir. Bedeli kontroldür: kütüphanenin API’si serinin can() imzasıyla eşleşmeyebilir, bakımı yavaşlayabilir veya durabilir, class tabanlı tasarımlar React Server Components ile çakışır ve bir izin reddini debug etmek, ekibin yazdığı bir fonksiyon yerine başkasının iç yapısını anlamayı gerektirir.

Karar Çerçevesi: Kendin Yazmak mı, Kütüphane mi?#

Hayır

Evet

Evet

Evet

Hayır

Hayır

Hayır

Evet

Hayır

Evet

Evet

Hayır

Yetkilendirme

Gerekli

Ekipte

yetkilendirme

uzmanlığı var mı?

Kütüphane Kullan

CASL, Cerbos, Permit.io

Uyumluluk

gereksinimleri?

SOC2, HIPAA

Audit trail veya

policy-as-code

gerekli mi?

Harici PDP

Cerbos, OPA, Cedar

Kaynak başına

3+ bağlamsal

koşul?

Özel RBAC

Post 103

Field-level +

DB filtreleme

gerekli mi?

Özel ABAC

Post 104

Ekip 300-500 satır

auth kodu

sürdürebilir mi?

Özel Tam ABAC

Post 104-105

In-Code vs. DSL Tabanlı Yaklaşımlar#

YaklaşımÖrneklerGüçlü YönlerZayıf Yönler
In-code (TypeScript)Özel, CASLType-safe, runtime yükü yok, tanıdık dilDeployment’a bağlı, runtime değişikliği yok
DSL / Policy diliOPA/Rego, Cedar, Cerbos (YAML)Uygulama kodundan ayrık, geliştirici olmayanlar düzenleyebilir, denetlenebilirÖğrenme eğrisi, araç yükü, gecikme
HibritPermit.io, özel DB’de saklanan kurallarRuntime yapılandırılabilir + kod tabanlı varsayılanlarKarmaşıklık, tutarlılık zorlukları

CASL Entegrasyonu#

Neden CASL#

CASL, en popüler JavaScript/TypeScript yetkilendirme kütüphanesidir (~6KB çekirdek). İzomorfiktir (sunucu ve istemcide çalışır), ABAC koşullarını, alan düzeyinde izinleri ve veritabanı sorgu dönüşümünü destekler. Seri zaten CASL’ın sağladığı her şeyi oluşturduğundan, doğrudan özellik bazında karşılaştırma mümkündür.

npm install @casl/ability @casl/prisma

Migration: AbilityBuilder#

Post 104’teki özel PermissionBuilder, CASL’ın AbilityBuilder’ına eşlenir:

Özel (Post 104-105):

const permissions = new PermissionBuilder()
  .role('admin')
    .can('manage', 'document')
  .role('editor')
    .can(['read', 'update'], 'document', [
      (user, doc) => user.departmentId === doc.departmentId,
    ])
  .role('author')
    .can(['read', 'update'], 'document', [
      (user, doc) => doc.authorId === user.userId,
    ])
  .build();

CASL karşılığı:

import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';

type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
type Subjects = 'Document' | 'Project' | 'all';
type AppAbility = MongoAbility<[Actions, Subjects]>;

function defineAbilitiesFor(user: User): AppAbility {
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(
    createMongoAbility
  );

  if (user.role === 'admin') {
    can('manage', 'all');
  }

  if (user.role === 'editor') {
    can(['read', 'update'], 'Document', { departmentId: user.departmentId });
  }

  if (user.role === 'author') {
    can(['read', 'update'], 'Document', { authorId: user.userId });
    can('create', 'Document');
  }

  if (user.role === 'viewer') {
    can('read', 'Document', { status: 'published' });
  }

  return build();
}

Temel API farklılıkları:

  • Koşullar MongoDB tarzı nesnelerdir ({ authorId: user.userId }); özel builder fonksiyon kullanıyordu
  • Roller için builder-pattern zincirleme yok; kullanıcı rolünde if/else dallanma kullanılır
  • Negatif kurallar için cannot() (CASL’a özel; özel sistemde bu yoktu)
  • 'manage' tüm CRUD aksiyonları için CASL’ın joker karakteri; 'all' tüm subject’ler için

subject() Helper’ı ve Getirdiği Kısıtlar#

CASL, kontrol edilen nesnenin türünü bilmek zorundadır. Class kullanıldığında bunu class adından çıkarır. TypeScript uygulamalarının genellikle tercih ettiği düz nesnelerde ise subject() helper’ı gerekir:

import { subject } from '@casl/ability';

// CASL düz nesnelerin sarmalanmasını gerektirir
ability.can('update', subject('Document', document));

// Sorun: subject() nesneyi __caslSubjectType__ ekleyerek mutasyona uğratır
// Bu React Server Components ile çakışır (nesneler serileştirilebilir olmalı)

Geçici çözüm 1: Object spreading

// Orijinali mutasyona uğratmamak için kopya oluştur
ability.can('update', subject('Document', { ...document }));

Geçici çözüm 2: Özel detectSubjectType

import { createMongoAbility } from '@casl/ability';

const ability = createMongoAbility(rules, {
  detectSubjectType: (object) => {
    // Class adı yerine özel bir özellik kullan
    return object.__type || object.constructor?.modelName || 'unknown';
  },
});

// Service layer'da döndürülen nesnelere __type ekle
function toDocumentDTO(doc: Document): DocumentDTO & { __type: 'Document' } {
  return { ...doc, __type: 'Document' };
}

Geçici çözüm 3: Lambda matcher ile PureAbility (RSC uyumlu)

import {
  PureAbility,
  AbilityBuilder,
  type AbilityTuple,
  type MatchConditions,
} from '@casl/ability';

type AppAbility = PureAbility<AbilityTuple, MatchConditions>;
const lambdaMatcher = (matchConditions: MatchConditions) => matchConditions;

function defineAbilityFor(user: User): AppAbility {
  const { can, build } = new AbilityBuilder<AppAbility>(PureAbility);

  // MongoDB tarzı yerine lambda koşulları: class'lar olmadan çalışır
  can('read', 'Document', ({ authorId }) => authorId === user.userId);

  return build({ conditionsMatcher: lambdaMatcher });
}

Tip

PureAbility + lambda matcher yaklaşımı en RSC-uyumlu seçenektir, ancak CASL’ın MongoDB tarzı sorgu operatörlerini ve Prisma entegrasyonunu kaybeder. CASL’ın tam özellik seti ile modern React uyumluluğu arasında gerçek bir trade-off vardır.

CASL’da Tenant İzolasyonu#

function defineAbilitiesFor(user: User): AppAbility {
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(
    createMongoAbility
  );

  // Tenant izolasyonu: tenantId HER kurala eklenmeli
  // CASL'da global() koşulu yok

  if (user.role === 'admin') {
    can('manage', 'Document', { tenantId: user.tenantId });
    can('manage', 'Project', { tenantId: user.tenantId });
  }

  if (user.role === 'editor') {
    can(['read', 'update'], 'Document', {
      tenantId: user.tenantId,
      departmentId: user.departmentId,
    });
  }

  // Platform yöneticisi: tenantId filtresi yok
  if (user.role === 'platform_admin') {
    can('manage', 'all');
  }

  return build();
}

Özel sistemde her kurala otomatik olarak uygulanan bir global() koşulu vardı. CASL, tenantId’yi her kurala ayrı ayrı eklemeyi gerektirir ve bir kuralda eksik bırakmak çapraz tenant sızıntısı oluşturur.

CASL Alan ve Veritabanı Entegrasyonu#

permittedFieldsOf ile Alan Düzeyinde İzinler#

import { permittedFieldsOf } from '@casl/ability/extra';

// Alan düzeyinde kurallar tanımla
can('read', 'Document', ['title', 'content', 'status'], {
  status: 'published',
});
can(
  'read',
  'Document',
  ['title', 'content', 'status', 'internalNotes', 'reviewComments'],
  { authorId: user.userId }
);

// Belirli bir doküman için izin verilen alanları al
const fields = permittedFieldsOf(ability, 'read', 'Document', {
  fieldsFrom: (rule) =>
    rule.fields || [
      'title',
      'content',
      'status',
      'authorId',
      'internalNotes',
      'reviewComments',
      'publishedAt',
    ],
});

Post 105’teki özel getVisibleFields() ile karşılaştırın: kavram aynı, API farklı. CASL, bir kuralda alan kısıtlaması olmadığında tüm olası alanları döndüren bir fieldsFrom callback’i gerektirir.

CASL AST’den Prisma Sorgu Dönüşümü#

import { accessibleBy } from '@casl/prisma';

// CASL kurallarını Prisma where koşuluna dönüştür
const documents = await prisma.document.findMany({
  where: accessibleBy(ability).Document,
});

// İş mantığı filtreleriyle birleştir
const documents = await prisma.document.findMany({
  where: {
    AND: [accessibleBy(ability).Document, { projectId: projectId }],
  },
});

Post 105’teki özel toWhereClause() ile karşılaştırma:

  • CASL’ın accessibleBy() fonksiyonu MongoDB tarzı koşulları Prisma where sözdizimine dönüştürür
  • Özel toWhereClause(), toFilter callback’leri olan koşul tanımlayıcılarını kullanır
  • CASL, birden fazla eşleşen kural arasında OR mantığını otomatik olarak yönetir
  • CASL, hiçbir kural eşleşmezse ForbiddenError fırlatır (fail-closed)

Warning

accessibleBy() yalnızca MongoDB tarzı koşullarla (createMongoAbility’den) çalışır, lambda koşullarıyla (PureAbility) çalışmaz. RSC uyumlu PureAbility pattern’ini kullanıyorsanız, Prisma sorgu dönüşümünü kaybedersiniz.

Dört Yaklaşım Arasında Seçim#

RBAC vs. Özel ABAC vs. CASL ABAC#

BoyutRBAC (Post 103)Özel ABAC (Post 104-105)CASL ABAC (Post 106)
Çekirdek mantıkRol-izin aramasıKoşullu policy engineMongoDB koşullu AbilityBuilder
Auth kod satır sayısı~80 (matris + can())~300-500 (builder + engine + alan + DB)~50 (defineAbilitiesFor) + kütüphane
can() imzasıcan(role, resource, action)can(user, action, resource, data?, env?)ability.can(action, subject(type, data))
Bağlamsal koşullarHayır (helper gerektirir)Evet (policy builder’da inline)Evet (MongoDB tarzı nesneler veya lambda’lar)
Alan düzeyinde izinlerHayırEvet (getVisibleFields, pickPermittedFields)Evet (permittedFieldsOf)
DB sorgu filtrelemeHayırEvet (toWhereClause())Evet (accessibleBy() ile Prisma)
Çevre kurallarıHayırEvet (zaman, IP, flag’ler)Kısmi (özel koşullar ile)
Multi-tenancyMetod başına manuel kontrolGlobal koşul (otomatik)Kural başına tenantId (kural başına manuel)
Type safetyTam (generic’ler, mapped type’lar)Tam (resource-action generic’ler)İyi (typed action/subject, koşullarda daha zayıf)
RSC uyumluluğuTam (düz fonksiyonlar)Tam (düz fonksiyonlar)Kısmi (subject() mutasyon sorunu)
Negatif kurallarHayırHayırEvet (cannot())
BakımEkip sahipliğindeEkip sahipliğindeKütüphane bakımlı çekirdek
Bundle boyutu0 (yerleşik)0 (yerleşik)~6KB (çekirdek) + adaptörler

Sistem Seçim Kılavuzu#

2-4 basit rol

Yok

1-2 helper

3+

5+ rol veya

rol patlaması

Evet

Hayır

Evet

Hayır

Hayır

Evet

İzin Sistemi

Oluşturma

Kaç rol var?

Bağlamsal koşullar?

sahiplik, departman,

durum

RBAC

Post 103

En basit, en hızlı

RBAC + Helper

Post 103 ile if/else

ABAC Düşün

Ekip özel kodu

sürdürebilir mi?

Field-level veya

DB filtreleme

gerekli mi?

Özel ABAC

Post 104

Özel Tam ABAC

Post 104-105

CASL

Post 106

Multi-tenant?

Tamam

Tenant Katmanı Ekle

Global Koşul

Her Birini Ne Zaman Seçmeli#

RBAC (Post 103)

Her ekip boyutunda çalışır; dahili araçlar, basit SaaS ve net rollere sahip içerik platformları için uygundur: 2-4 rol, izinler yalnızca role bağlıdır. İzin gereksinimleri “bu rol bu şeyleri yapabilir” şeklinde temiz bir eşleme oluşturduğunda seçilir.

Özel ABAC (Post 104-105)

Yetkilendirme uzmanlığına sahip ve auth kodunu sürdürmeye istekli bir ekip, karmaşık iş kurallarına sahip SaaS, alan düzeyinde görünürlük ve büyük veri kümeleri için bu seçeneğe yönelir: sahiplik, departman, durum ve zaman koşulları can() içine girer; bu noktada can() kaynak başına 3+ bağlamsal koşulu değerlendirir. Bu denge, auth bakımı için ekip bant genişliği azaldığında veya birden fazla ORM’de DB sorgu adaptörlerine ihtiyaç duyulduğunda değişir.

CASL ABAC (Post 106)

CASL, zamanını auth iç yapısı yerine iş mantığına ayırmayı tercih eden bir ekibe uyar: Prisma/MongoDB kullanan, alan izinleri ile DB filtreleme gerektiren SaaS kurarken özel kodu, özel ABAC özellik seti CASL’ın yetenekleriyle zaten eşleştiği anda tercih ettiği bir kütüphane API’siyle değiştirir. Düz nesnelerle yoğun RSC kullanımı, çevre koşulları gereksinimi veya global tenant izolasyonu gereksinimi bu seçeneği devre dışı bırakır.

Harici PDP (Cerbos, OPA, Cedar)

Bu seçenekten en çok fayda, genellikle mikroservis veya polyglot bir stack’te çalışan adanmış bir platform veya güvenlik ekibi çıkarır; birden fazla servisin tutarlı yetkilendirme uygulaması gerektiği ve uyumluluğun ayrıştırılmış, denetlenebilir bir politika yönetimi istediği durumlarda bu seçeneğe yönelin.

Multi-Tenant Yetkilendirme Nerede Kırılır#

  1. Bir CASL kuralında tenant izolasyonunu unutma: CASL’da global koşul yoktur. Bir kuralda tenantId eksik bırakmak çapraz tenant sızıntısı oluşturur. Her platform-admin olmayan kuralın tenantId içerdiğini doğrulayan bir lint kuralı veya birim testi yazın.

  2. CASL’ın RSC ile sorunsuz çalıştığını varsaymak: subject() helper’ı nesneleri mutasyona uğratır. React Server Components serileştirilebilir veri gerektirir. CASL Entegrasyonu bölümündeki üç geçici çözümden birini kullanın.

  3. PureAbility Prisma entegrasyonunu kaybeder: RSC uyumlu PureAbility + lambda matcher pattern’i koşulları Prisma where koşullarına dönüştüremez. Ekipler RSC uyumluluğu ile DB sorgu filtreleme arasında seçim yapmalıdır.

  4. Erken aşırı mühendislik: RBAC başarısız olmadan ABAC’a veya CASL’a geçmek erkendir.

  5. Multi-tenancy’yi sonradan düşünme: Şema oluşturulduktan sonra her tabloya tenant_id eklemek zorlu bir migration’dır. İlk sürüm yalnızca bir tenant’a sahip olsa bile, tenant izolasyonunu baştan tasarlayın.

  6. Platform yöneticisini tenant yöneticisi ile karıştırma: Platform yöneticileri tüm tenant’ları yönetir (çapraz tenant erişimi). Tenant yöneticileri yalnızca kendi tenant’larını yönetir. Bu rolleri karıştırmak ya aşırı izin verici tenant yöneticileri ya da yetersiz izin verici platform yöneticileri oluşturur.

  7. Harici PDP’yi çok erken seçme: Cerbos, OPA ve Cedar altyapı karmaşıklığı ekler. Monolitik bir Next.js uygulaması için süreç içi yetkilendirme (özel veya CASL) daha basit ve hızlıdır. Harici PDP’ler, yetkilendirme kararlarının bağımsız olarak dağıtılan servisler arasında paylaşılması gerektiğinde anlam kazanır.

  8. Çapraz tenant senaryolarını test etmeme: Birim testleri genellikle tek bir tenant ID kullanır. Kullanıcı A’nın (tenant 1) Kullanıcı B’nin (tenant 2) dokümanına erişmeye çalıştığı açık test senaryoları ekleyin. Bu testler eksik tenant filtrelerini yakalar.

Serinin Değerlendirmesi#

Yedi Hedef Karnesi#

Post 101 herhangi bir izin sistemi için yedi hedef belirledi. Her yaklaşımın puanlaması:

HedefDağınık (101)Service Layer (102)RBAC (103)Özel ABAC (104-105)CASL (106)
Yetkisiz erişimi önleKısmiEvetEvetEvetEvet
Tutarlı (tek doğruluk kaynağı)HayırEvetEvetEvetEvet
Otomatik zorunlu kılmaHayırMimariMimariMimari + GlobalMimari
Güncellenmesi kolayHayırOrtaEvet (matris)Evet (builder)Evet (kurallar)
DenetlenebilirHayırOrtaEvet (matris)Evet (builder)Evet (kurallar)
PerformanslıDeğişkenEvet + cacheEvet (O(1) arama)Evet (koşul değerlendirme)Evet (koşul değerlendirme)
Type-safeHayırKısmiTamTamİyi

Serinin Mimari Evrimi#

Post 101

Dağınık Kontroller

(Sorun)

Post 102

Service Layer

(Mimari)

Post 103

RBAC

(Karar Motoru)

Post 104

ABAC

(Policy Engine)

Post 105

Field-Level + DB

(Genişletmeler)

Post 106

Multi-Tenancy + CASL

(Üretim)

Post 102’deki service layer, altı yazı boyunca hiçbir zaman değişmez. İçindeki karar motoru basit rol kontrollerinden RBAC’a, ABAC’a ve CASL’a evrilirken bu katman her yetkilendirme yaklaşımı için zorunlu kılma noktası olarak kalır; böylece her yükseltme tek bir katmanın içinde sınırlı kalır.

Servis Sınırları Arasında Yetkilendirme#

Seri monolitik bir Next.js uygulamasına odaklandı. Uygulamalar büyüdükçe, yetkilendirme kararları servis sınırları arasında çalışmalıdır; bu genellikle üç pattern’den biriyle olur:

PatternMekanizmaTrade-off
Merkezi Yetkilendirme ServisiTek bir servis tüm izin kararlarını değerlendirir; diğer servisler gRPC/HTTP ile çağırırTek doğruluk kaynağı, ama tek hata noktası ve her istekte ağ gecikmesi
Gömülü PDP (Sidecar)Her mikroservis kendi policy engine’ini çalıştırır (OPA sidecar, Cerbos sidecar), bir politika yöneticisi tarafından merkezi olarak senkronize edilirKararlar için ağ atlaması yok, ama politika senkronizasyonu karmaşıklığı ve sürüm kayması riski var
Token Tabanlı Claim’lerYetkilendirme verileri JWT claim’lerine gömülür (roller, izinler, tenantId); servisler ek kontrol olmadan token’a güvenirEn basit altyapı, ama eskimiş claim’ler ve kaynak düzeyinde yetkilendirme yok

Pattern 3: Token Claim

API Gateway

JWT Issuer

Servis A

(token'a güven)

Servis B

(token'a güven)

Pattern 2: Sidecar PDP

sync

sync

Servis A

PDP Sidecar

Servis B

PDP Sidecar

Politika Yöneticisi

Pattern 1: Merkezi

gRPC

gRPC

Servis A

Auth Servisi

Servis B

Monolitten mikroservislere geçen ekipler için: servisler arası auth için Pattern 3 (token claim’ler) ile başlayın, servisler arasında ayrıntılı kaynak düzeyinde yetkilendirme gerektiğinde Pattern 2 (gömülü PDP) ekleyin.

İzinleri Nerede Saklamalı: Kod mu, Veritabanı mı?#

YaklaşımGüçlü YönlerZayıf YönlerNe Zaman Kullanmalı
Yalnızca kod (bu seri)Type-safe, sürüm kontrollü, CI/CD ile test edilebilirDeğişiklikler için deployment gerektirirİzin kuralları uygulama koduyla birlikte değişir
Veritabanında saklananRuntime yapılandırılabilir, tenant özelleştirilebilirDerleme zamanı güvenliği yok, migration karmaşıklığıTenant’lar özel roller/izinler gerektirir
HibritKodda varsayılan kurallar + DB’de geçersiz kılmalarİki sistemin karmaşıklığı, çakışma çözümüTenant başına özelleştirme gerektiren SaaS

Hibrit pattern üretim SaaS için iyi çalışır: varsayılan izin setini kodda tanımlayın (type-safe, test edilmiş), tenant’ların belirli kuralları veritabanı tablosu ile geçersiz kılmasına izin verin. can() fonksiyonu önce kod tabanlı kuralları kontrol eder, sonra veritabanı geçersiz kılmalarını uygular.

Seri Özeti#

Altı yazı boyunca izin sistemi, dağınık if ifadelerinden üretim kalitesinde bir yetkilendirme mimarisine evrildi:

  1. Post 101: Sorunu belirledi: dağınık kontroller, tutarsız zorunlu kılma, fail-closed varsayılanı yok
  2. Post 102: Mimariyi oluşturdu: tek zorunlu kılma noktası olarak service layer
  3. Post 103: İlk karar motorunu ekledi: generic constraint’lerle type-safe RBAC
  4. Post 104: Rol tabanlı aramayı attribute tabanlı politikalarla değiştirdi: sahiplik, departman, durum koşulları
  5. Post 105: ABAC’ı çevre kuralları, alan düzeyinde izinler ve veritabanı sorgu filtreleme ile genişletti
  6. Post 106: Multi-tenancy ekledi, CASL’ı bir kütüphane alternatifi olarak değerlendirdi ve karar çerçevesini ortaya koydu

İzin kuralları rollerle temiz biçimde eşleştiği sürece varsayılan RBAC’tır; tek bir kaynak can() içinde üç veya daha fazla bağlamsal koşul gerektirmeye başladığında bir sonraki adıma geçilir.

Kaynaklar#

Ölçeklenebilir İzin Sistemleri

TypeScript ve Next.js ile ölçeklenebilir izin sistemleri oluşturma rehberi. Basit kontrol mekanizmalarından RBAC ve ABAC'a, oradan multi-tenant yetkilendirme sistemlerine kadar kapsamlı bir seri.

İlerleme 6/6 yazı tamamlandı

İlgili yazılar