İçeriğe atla

RBAC: TypeScript ile Type-Safe Rol Bazlı Erişim Kontrolü

TypeScript ile type-safe bir RBAC sistemi oluşturun, birleşik bir can() fonksiyonu yazın, UI ve backend'de izinleri senkronize edin ve RBAC'ın sınırlarını anlayın.

Ayhan Sipahi Ayhan Sipahi

Post 102 yetkilendirmeyi service layer’da merkezileştirdi; izin kontrollerinin nerede yapılacağı sorusunu çözdü. Ancak service layer içindeki izin kuralları hala sabit kodlanmış if/else zincirleri: if (session.role === 'admin') return true. Yeni bir rol eklemek, her servis dosyasındaki her yardımcı fonksiyonu değiştirmek anlamına geliyor.

O zincirlerin yerini RBAC (Rol Bazlı Erişim Kontrolü) alıyor. Modeli Ferraiolo ve Kuhn 1992’de biçimlendirdi, NIST ise INCITS 359-2004 olarak standartlaştırdı. Tek bir type-safe can() fonksiyonu sabit kodlanmış kontrollerin tamamını devralıyor. Hem sunucu hem istemci tarafında çalışıyor ve Post 102’nin açık bıraktığı izin mantığı tekrarını kapatıyor.

RBAC Nedir?#

NIST Modeli#

RBAC, izinleri doğrudan kullanıcılara değil rollere atar. Kullanıcılar, rollere atanarak izinleri edinir. Bu dolaylılık işin özüdür: birisi görev değiştirdiğinde rolünü değiştirirsiniz, elli ayrı izin arkasından gelir.

NIST modeli üç temel bileşen tanımlar:

  • Kullanıcılar: Kimliği doğrulanmış kimlikler (bizim durumumuzda verifySession())
  • Roller: İsimlendirilmiş görev fonksiyonları: admin, editor, author, viewer
  • İzinler: Kaynaklar üzerinde onaylanmış operasyonlar: document:create, project:read

Roller olmadan, izin yönetimi N kullanıcı çarpı M izin ataması gerektirir. Rollerle çok daha küçük bir küme yönetirsiniz: N kullanıcı-rol ataması artı R rol-izin eşlemesi.

Örnek Domain’de RBAC#

NIST kavramlarını Post 101-102’deki domain’e eşleyelim:

NIST KavramıBizim Domainimiz
KullanıcıverifySession() ile doğrulanmış oturum
Roladmin, editor, author, viewer
Nesne (Kaynak)document, project
Operasyon (Eylem)create, read, update, delete, publish
İzinRol-(kaynak, eylem) eşlemesi

İzinler

Roller

Kullanıcılar

Alice

Bob

Carol

Dave

admin

editor

author

viewer

document:create

document:read

document:update

document:delete

document:publish

project:create

project:read

Kullanıcılar rollere, roller de izinlere bağlanır. Hiçbir kullanıcı doğrudan bir izne bağlanmaz; RBAC yapısının tamamı bu dolaylılıktan ibarettir.

TypeScript İzin Tanımları#

Kaynaklar ve Eylemler#

İlk adım, hangi kaynakların ve eylemlerin var olduğunu tanımlamaktır. TypeScript’in as const ifadesi, string[]’e genişletmek yerine literal türleri korur.

// lib/permissions.ts
// 'server-only' yok -- bu dosya sunucu ve istemci arasında paylaşılıyor

export const RESOURCES = ['document', 'project'] as const;
export type Resource = (typeof RESOURCES)[number];

export const ACTIONS = [
  'create',
  'read',
  'update',
  'delete',
  'publish',
] as const;
export type Action = (typeof ACTIONS)[number];

// Template literal türü: "document:create" | "document:read" | ...
export type Permission = `${Resource}:${Action}`;

Permission türü, tüm geçerli Resource:Action kombinasyonlarının bir birleşimidir. 'document:fly' yazmak derleme zamanı hatası olur. TypeScript bu birleşimi iki as const dizisinden otomatik olarak oluşturur.

Roller#

export const ROLES = ['admin', 'editor', 'author', 'viewer'] as const;
export type Role = (typeof ROLES)[number];

İzin Haritası#

RBAC sisteminin çekirdeği budur: her rolün izinlerini tanımlayan tek bir nesne.

export const ROLE_PERMISSIONS = {
  admin: [
    'document:create',
    'document:read',
    'document:update',
    'document:delete',
    'document:publish',
    'project:create',
    'project:read',
    'project:update',
    'project:delete',
  ],
  editor: [
    'document:create',
    'document:read',
    'document:update',
    'document:publish',
    'project:read',
  ],
  author: [
    'document:create',
    'document:read',
    'document:update',
    'project:read',
  ],
  viewer: [
    'document:read',
    'project:read',
  ],
} as const satisfies Record<Role, readonly Permission[]>;

Burada iki TypeScript özelliği birlikte çalışır:

  • as const literal türleri korur; her izin string değil 'document:create' olarak kalır
  • satisfies şekli genişletmeden doğrular. Bir rol yanlış yazılırsa veya bir izin dizgisi geçersizse (örneğin 'document:fly'), TypeScript bunu derleme zamanında yakalar. Ancak çıkarsanan tür yine de belirli literal değerleri koruyarak otomatik tamamlama sağlar

Neden tür anotasyonu yerine satisfies? const ROLE_PERMISSIONS: Record<Role, Permission[]> yazmak tüm değerleri Permission[]’e genişletir ve belirli literal bilgiyi kaybeder. satisfies ile TypeScript her rolün tam olarak hangi izinlere sahip olduğunu bilir.

İzin Matrisi#

Yukarıdaki ROLE_PERMISSIONS nesnesi bir tablo olarak görselleştirilebilir. Bu tablo gözden geçirilebilir, denetlenebilir ve kod okumayı gerektirmez.

İzinadmineditorauthorviewer
document:createevetevetevet
document:readevetevetevetevet
document:updateevetevetevet
document:deleteevet
document:publishevetevet
project:createevet
project:readevetevetevetevet
project:updateevet
project:deleteevet

Bu tablo izin sisteminin kendisidir. “Bir editör ne yapabilir?” sorusu tek bakışta cevaplanır.

can() Fonksiyonu#

İmplementasyon#

can() bir rol, bir kaynak ve bir eylem alır, ardından boolean döner.

// lib/permissions.ts (devam)

export function can(
  role: Role,
  resource: Resource,
  action: Action
): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  const required = `${resource}:${action}` as Permission;
  return (permissions as readonly Permission[]).includes(required);
}

// Alternatif: tam izin dizgisi ile kontrol
export function hasPermission(
  role: Role,
  permission: Permission
): boolean {
  return (ROLE_PERMISSIONS[role] as readonly Permission[]).includes(
    permission
  );
}

Temel özellikler:

  • Saf fonksiyon: Veritabanı erişimi yok, async yok, yan etki yok. Sıfır ek yük.
  • Type-safe: TypeScript role’ü dört literal türden birine daraltır. Geçersiz kaynak veya eylem dizgileri derleme zamanı hatalarıdır.
  • İki form: can(role, 'document', 'update') servis metotlarında akıcı durur, hasPermission(role, 'document:update') ise UI bileşenlerinde. İkisi de aynı ROLE_PERMISSIONS haritasını sorgular.

Öncesi ve Sonrası: Service Layer Refactor’ü#

Öncesi (Post 102’den sabit kodlanmış kontroller):

function canEditDocument(
  session: { userId: string; role: string },
  document: DocumentWithProject
): boolean {
  if (session.role === 'admin') return true;

  const membership = document.project.members.find(
    (m) => m.userId === session.userId
  );
  if (!membership) return false;

  if (membership.role === 'editor') return true;

  if (
    membership.role === 'author' &&
    document.authorId === session.userId
  ) {
    return true;
  }

  return false;
}

Sonrası (can() ile RBAC):

function canEditDocument(
  session: { userId: string; role: Role },
  document: DocumentWithProject
): boolean {
  // Rol bazlı kontrol: rolün document:update izni var mı?
  if (can(session.role, 'document', 'update')) return true;

  // Sahiplik kontrolü: yazarlar kendi belgelerini düzenleyebilir
  // (RBAC'ın sınırına ulaştığımız yer -- Sınırlamalar bölümüne bakın)
  const membership = document.project.members.find(
    (m) => m.userId === session.userId
  );
  if (!membership) return false;

  if (
    membership.role === 'author' &&
    document.authorId === session.userId
  ) {
    return true;
  }

  return false;
}

Ne değişti:

  • if (session.role === 'admin') return true ifadesi if (can(session.role, 'document', 'update')) return true oldu
  • Admin kontrolü artık özel durum değil. admin rolünün izin haritasında document:update olduğu için çalışıyor.
  • Belgeleri düzenleyebilen bir “moderator” rolü eklemek için ROLE_PERMISSIONS’a tek satır yazmanız yeterli; bu fonksiyona dokunmazsınız.
  • Sahiplik kontrolü (document.authorId === session.userId) hala bir if/else olarak kalıyor. RBAC “sadece kendinize ait olanlar” ifadesini karşılayamaz. Bu, açıkça RBAC’ın sınırlaması ve Post 104’teki ABAC’ın motivasyonudur.

Ayrıntılı İzinler#

CRUD Ötesi: Eylemleri Genişletme#

Bir uygulama büyüdükçe basit CRUD tüm operasyonları kapsamaz. Diziye yeni eylemler eklenebilir:

export const ACTIONS = [
  'create',
  'read',
  'update',
  'delete',
  'publish',
  'archive',
  'invite-member',
  'manage-settings',
] as const;

Permission template literal türü otomatik olarak genişler. document:archive, project:invite-member ve project:manage-settings ek tür tanımı olmadan geçerli izin dizgileri haline gelir.

Departman Bazlı Yardımcı Fonksiyon#

Kuruluşlarda departmanlar olduğunda, yaygın bir kalıp hem rol iznini HEM DE departman üyeliğini kontrol etmektir:

function canEditInDepartment(
  session: { userId: string; role: Role; departmentId: string },
  document: DocumentWithProject
): boolean {
  // Adım 1: Rolün bu izni var mı?
  if (!can(session.role, 'document', 'update')) {
    return false;
  }

  // Adım 2: Kullanıcı aynı departmanda mı?
  if (session.departmentId !== document.project.departmentId) {
    // Admin departman kısıtlamasını atlar
    if (session.role !== 'admin') return false;
  }

  return true;
}

Departman kontrolünün can() fonksiyonunun dışında olduğuna dikkat edin. RBAC’ın can() fonksiyonu yalnızca roller ve izinler hakkında bilgi sahibidir. “Hangi departman” veya “hangi belirli kaynak” kavramı yoktur. Bu, bir veya iki bağlamsal kontrol için işe yarar. Ancak bunlar çoğaldıkça (departman, sahiplik, zaman, belge durumu) yardımcı fonksiyonlar artar. Aynı soruna geri döneriz.

Sahiplik Bazlı Yardımcı Fonksiyon#

function canModifyDocument(
  session: { userId: string; role: Role },
  document: { authorId: string }
): boolean {
  // RBAC ile global izin kontrolü
  if (can(session.role, 'document', 'update')) {
    return true;
  }

  // Sahiplik yedek kontrolü: yazarlar her zaman kendi belgelerini düzenleyebilir
  return document.authorId === session.userId;
}

Yine, sahiplik kontrolü RBAC’ın dışındadır. can() fonksiyonu “sadece kendi belgeleriniz” ifadesini karşılayamaz. Bu tasarım gereğidir. RBAC rolleri izinlere eşler, o kadar.

UI ve Backend Senkronizasyonu#

Paylaşılan İzin Modülü#

İşin püf noktası şu: lib/permissions.ts dosyasında 'server-only' import’u yok. Veritabanı erişimi, oturum yönetimi veya sunucuya özgü API içermez. Tür tanımları, sabit bir nesne ve saf fonksiyonlardan oluşan bir TypeScript modülüdür.

Bu, şunlar tarafından import edilebileceği anlamına gelir:

  • Service layer dosyaları (server-only): yetkilendirme için
  • React Server Component’ları: koşullu render için
  • React Client Component’ları: koşullu render için
  • Middleware: route düzeyinde kontroller için

Dördü de aynı dosyayı import ettiği için tek bir doğruluk kaynağı vardır ve tekrar oluşmaz.

lib/permissions.ts

ROLE_PERMISSIONS + can()

Service Layer

(server-only)

Server Component'lar

Client Component'lar

Middleware

Server Component Kullanımı#

Server Component’lar verifySession() çağırabilir ve can() fonksiyonunu doğrudan kullanabilir:

// components/document-actions.tsx
import { verifySession } from '@/lib/auth';
import { can, type Role } from '@/lib/permissions';

export async function DocumentActions({
  document,
}: {
  document: DocumentDTO;
}) {
  const session = await verifySession();
  if (!session) return null;

  const role = session.role as Role;

  return (
    <div>
      {can(role, 'document', 'update') && (
        <EditButton documentId={document.id} />
      )}
      {can(role, 'document', 'delete') && (
        <DeleteButton documentId={document.id} />
      )}
      {can(role, 'document', 'publish') && (
        <PublishButton documentId={document.id} />
      )}
    </div>
  );
}

Post 102’nin versiyonuyla karşılaştırın:

// Post 102: tekrarlanan mantık, sapma eğilimli
const canEdit =
  session.role === 'admin' ||
  session.role === 'editor' ||
  document.authorId === session.userId;

// Post 103: paylaşılan doğruluk kaynağı
const canEdit = can(role, 'document', 'update');

Bileşendeki can() çağrısı ve service layer’daki can() çağrısı aynı ROLE_PERMISSIONS nesnesini sorgular. Bir editör silme izni kazanırsa tek bir yerde değiştirin. Hem sunucu hem istemci değişikliği otomatik olarak yansıtır.

İzinleri Prop Olarak Alan Client Component#

Client Component’lar verifySession() çağıramaz. Çözüm: izinleri bir üst Server Component’ta çözümleyin ve prop olarak geçirin.

// Üst Server Component
import { verifySession } from '@/lib/auth';
import { can, type Role } from '@/lib/permissions';

export async function DocumentPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const session = await verifySession();
  const role = session?.role as Role;

  const permissions = {
    canEdit: can(role, 'document', 'update'),
    canDelete: can(role, 'document', 'delete'),
    canPublish: can(role, 'document', 'publish'),
  };

  return <DocumentToolbar permissions={permissions} />;
}
// Alt Client Component
'use client';

interface ToolbarProps {
  permissions: {
    canEdit: boolean;
    canDelete: boolean;
    canPublish: boolean;
  };
}

export function DocumentToolbar({ permissions }: ToolbarProps) {
  return (
    <div>
      {permissions.canEdit && <EditButton />}
      {permissions.canDelete && <DeleteButton />}
      {permissions.canPublish && <PublishButton />}
    </div>
  );
}

Client Component izin sistemi hakkında hiçbir şey bilmez. Boolean’lar alır ve buna göre render eder.

PermissionGate Bileşeni#

Şablonlarda tekrarlanan izin kontrolleri için yeniden kullanılabilir bir sarmalayıcı standart kodu azaltır:

// components/permission-gate.tsx
import { verifySession } from '@/lib/auth';
import {
  can,
  type Role,
  type Resource,
  type Action,
} from '@/lib/permissions';

export async function PermissionGate({
  resource,
  action,
  children,
  fallback = null,
}: {
  resource: Resource;
  action: Action;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const session = await verifySession();
  if (!session) return fallback;

  const role = session.role as Role;

  if (!can(role, resource, action)) {
    return fallback;
  }

  return children;
}

Kullanım:

<PermissionGate resource="document" action="delete">
  <DeleteButton documentId={document.id} />
</PermissionGate>

Warning

UI izin kontrolleri bir kullanıcı deneyimi özelliğidir ve tek başına hiçbir güvenlik sağlamaz. Gizlenmiş bir buton yine de doğrudan API isteğiyle çağrılabilir; bu yüzden güvenlik sınırı service layer olmaya devam eder. UI kontrolleri kullanıcıların yapamayacakları eylemleri görmesini engeller; service layer bu eylemleri çalıştırmasını engeller.

RBAC Sınırlamaları: can() Yetmediğinde#

Bağlamsal Kararlar#

can(role, 'document', 'update') genel bir soruyu yanıtlar: “Editörler belgeleri güncelleyebilir mi?” Ancak asıl soru genellikle şudur: “Bu editör bu belirli belgeyi güncelleyebilir mi?” Bu şunlara bağlıdır:

  • Editör belgenin projesinin üyesi mi?
  • Belge düzenleme için kilitli mi?
  • Belge “inceleme” durumunda mı?
  • Kullanıcı belgenin yazarı mı?

Bunların hiçbiri rol-izin haritasında ifade edilemez. Kullanıcının, kaynağın ve ortamın niteliklerini gerektirirler.

// RBAC bunu yanıtlayabilir:
can('editor', 'document', 'update'); // true

// RBAC bunu yanıtlayamaz:
// "Editör Bob, XYZ belgesini güncelleyebilir mi?"
// Bağlıdır: Bob'un proje üyeliği, XYZ'nin durumu, XYZ'nin yazarı

İzin Matrisi Patlaması#

Gereksinimler büyüdükçe, ekipler giderek daha spesifik izinler oluşturur:

  • document:update: genel
  • document:update-own: sadece kendinize ait
  • document:update-in-department: sadece departmanınızdaki
  • document:update-draft: sadece taslaklar
  • document:update-published: sadece yayınlanmış belgeler

İzin matrisi patlar. Bu, NIST’in “rol patlaması” sorununun kod düzeyindeki karşılığıdır. Her uç durum için yeni roller oluşturmak yerine yeni izin dizgileri oluşturuyoruz; aynı sorunun farklı bir görünümü.

Yardımcı Fonksiyon Çoğalması#

RBAC’ın yanında bağlamsal kontrolleri yönetmek için yardımcı fonksiyonlar çoğalır:

// Bunların hepsi can() ile birlikte var
canEditOwnDocument(session, document);
canEditInDepartment(session, document);
canPublishInReviewStatus(session, document);
canDeleteIfNotLocked(session, document);
canAccessDraftInProject(session, document, project);

Her biri özel bir if/else zinciridir. can() fonksiyonu rol kontrolünü yapar, ancak bağlamsal mantık hala zorunlu koddur. Post 102’den ilerleme kaydettik, ancak bağlamsal kontroller dağınık kalmaya devam ediyor.

RBAC Ne Zaman Yeter, Ne Zaman Yetmez#

Evet

Hayır

Evet

1-2 uç durum

İzin sadece

kullanıcının rolüne

mi bağlıdır?

RBAC yeterli

can(role, resource, action)

Kaynak niteliklerine,

sahipliğe veya

bağlama mı bağlı?

ABAC gerekli (Post 104)

can(user, action, resource)

RBAC + yardımcı fonksiyonlar

(mevcut yaklaşım)

RBAC, izinler birincil olarak kullanıcının rolüne bağlı olduğunda doğru araçtır: “Admin’ler her şeyi yapabilir”, “Editörler güncelleyip yayınlayabilir”, “Viewer’lar sadece okuyabilir.” RBAC, izinler bağlama bağlı olduğunda yanlış araçtır: sahiplik, kaynak durumu, takım üyeliği veya çevresel koşullar.

Sonraki Adım#

Her bağlamsal kontrol (sahiplik, departman kapsamı, belge durumu) hala RBAC’ın dışında, ayrı bir yardımcı fonksiyon olarak duruyor. Bunlar çoğaldıkça, sistem RBAC’ın sağladığı bildirimsel netliği kaybeder.

Post 104’te Nitelik Bazlı Erişim Kontrolü’nü (ABAC) tanıtıyoruz. can() fonksiyonu, kullanıcının, kaynağın ve ortamın niteliklerini bir politika motoru aracılığıyla değerlendirmeye evrilir. Sahiplik, departman kapsamı ve kaynak durumu, motorun değerlendirdiği politika kurallarına dönüşür.

// Önizleme -- tam uygulama Post 104'te
// Şu an:
if (can(session.role, 'document', 'update')) {
  // sahiplik kontrolü hala ayrı
}
// Bunun yerine:
if (evaluate(session, 'update', document)) {
  // sahiplik, departman, durum -- hepsi tek bir politikada
}

Service layer ve genel mimari olduğu gibi kalıyor; sadece izin kontrollerinin içindeki karar motoru gelişiyor.

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 3/6 yazı tamamlandı

İlgili yazılar