mehmetozer.dev
senior pozisyonlara açığımİletişime geçin
← Tüm yazılar

Castra: Cloudflare Workers + D1 üzerinde, kendi hesabınızda çalışan ve AI ajanlarının güvenle kullanabileceği bir backend

Castra tek bir kısıtlamadan doğdu: D1'de row-level security yok.

Bunu bir hata olarak görmüyorum. D1 size edge'de SQLite veriyor, yönetilecek sunucu yok ve ücretsiz katman kişisel bir projeyi rahatça karşılıyor. Karşılığında bir Postgres kullanıcısının hiç düşünmeden kullandığı şeylerden vazgeçiyorsunuz: RLS, interaktif transaction'lar, istekler arası satır kilitleri. "Bu kullanıcı bu satırı okuyabilir, sadece bu satırı" gibi bir kurala ihtiyacınız varsa, onu kendiniz yazıyorsunuz.

Bunun genel çözümü kodun yanında yaşayan bir policy dili oluyor: bir dashboard, bir YAML dosyası ya da teste import edemediğiniz bir runtime. Beni rahatsız eden hiçbir zaman sözdizimi olmadı. Rahatsız eden şu: kural ve sorgu iki ayrı kod yolu haline geliyor ve "bu tablo gerçekten korunuyor mu?" sorusunun tek cevabı ikisini de okuyup birbirini tuttuğunu ummak oluyor.

Castra diğer yolu seçiyor. Kural repository'nizde duran TypeScript kodu, SQL'e derleniyor ve production'da çalışan da o derlenmiş SQL. REST istekleri bu motordan geçiyor, MCP sunucusuyla konuşan AI ajanları da. Yani bir ajan production'ın neyi uyguladığını sizin sözünüze güvenmek yerine kendisi kontrol edebiliyor.

Bugün satır bazlı kurallar istiyorsanız, Supabase'de gerçek Postgres RLS var, ama karşılığında çalıştırmanız ya da parasını ödemeniz gereken bir Postgres de var. PocketBase kuralları tek bir binary içinde veriyor, onu da sizin host edip yedeklemeniz gerekiyor. Castra ikisinin arasındaki durum için: Workers ve D1'i zaten seçmişsiniz ve kendi Cloudflare hesabınızdan çıkmadan kural yazmak istiyorsunuz.

Yapı

Tek bir Worker ve tek bir D1 veritabanı, sizin Cloudflare hesabınızda. Benim altyapımda hiçbir şey çalışmıyor, dolayısıyla ileride taşınmanız gereken bir proje de yok. Kurulum adımları yazının sonunda.

npm'de altı tane MIT lisanslı paket var: @castra-kit/core (policy motoru, şema snapshot'ı, REST router, OpenAPI), bunun yanında auth, mcp, app, cli ve create. Baştan sona TypeScript; Workers ve Hono üzerinde, sorgular için Kysely, kimlik için Better Auth, doğrulama için Zod.

Varsayılan olarak kapalı

Config'de policy'si olmayan bir tabloya hiç route açılmıyor. 403 ya da boş liste dönmüyor, route yok. Better Auth'un tabloları ve Castra'nın kendi _castra_* tabloları ise hiç yapılandırılamıyor.

import { authenticated, defineConfig, isPublic, owner, role } from "@castra-kit/core";

export default defineConfig({
  database: "d1",
  roles: ["admin", "editor"],
  tables: {
    posts: {
      read: isPublic()
        .where((q) => q.eb("status", "=", "published"))
        .or(owner("author_id"))
        .or(role("admin")),
      create: authenticated(),
      update: owner("author_id").or(role("editor", "admin")),
      delete: role("admin"),
      fields: { author_id: { setBy: "server" }, internal_notes: { visibleTo: ["admin"] } },
      filterable: ["status", "created_at"],
    },
  },
});
// audit_logs has no policy, so it is never exposed.

Öğrenilecek şeylerin çoğu bu kadar. Her tablonun dört işlemi var (read, create, update, delete). Her biri isPublic(), authenticated(), role(...), owner(column) ve kendi Kysely predicate'lerinizden kuruluyor, .and() ve .or() ile birleştiriliyor.

Kurallar SQL'e derleniyor

read, update ve delete kuralları, isteğin context'iyle birlikte bir WHERE parçasına derleniyor. Castra satırları sorgu döndükten sonra hiçbir zaman filtrelemiyor.

Bu bir implementasyon detayı gibi görünüyor, ama sonucun doğru mu yoksa sadece çoğunlukla doğru mu olacağını belirliyor:

  • ?limit=10, isteğin okumaya yetkili olduğu on satırı döndürüyor. Sonradan filtreleseydiniz on satır gelip dörde düşerdi.
  • count, gerçekten aldığınız satırlarla tutuyor, böylece sayfalama kaymıyor.
  • Hiçbir kod yolu kuralı uygulamayı unutamıyor, çünkü filtre router'ın zaten çalıştıracağı sorgunun içinde.

explain_query de ajana bu derlenmiş ifadenin aynısını veriyor. Yani ajanın gördüğü şey, birinin policy hakkında yazdığı özet değil, çalışacak olan SQL'in kendisi.

Bir WHERE ifadesi yetmiyor

Postgres RLS, USING ile WITH CHECK'i birbirinden ayırıyor. İlki hangi satırları görebileceğinize, ikincisi geride hangi satırları bırakmanıza izin verildiğine karar veriyor. Castra da aynı ayrımı koruyor.

Her create ve update, gelen payload'a değil, kaydedilmiş satıra karşı kontrol ediliyor. D1'de interaktif transaction olmadığı için kontrol, yazmayla aynı batch()'e, hemen arkasına giriyor. D1 bir batch'i tek bir SQL transaction olarak çalıştırıyor, yani herhangi bir statement hata fırlatırsa batch'in tamamı geri alınıyor.

Sorun şu ki sıfır satır döndüren bir guard hiçbir hata fırlatmıyor. O yüzden guard, hata fırlatmak üzere yazılmış tek bir SELECT:

select json_extract('{}', CASE WHEN exists(
         select 1 from "posts" where ("posts"."id" = ?) and <rule>)
       THEN '$' ELSE 'castra_write_check_failed' END)

Kaydedilmiş satır kuralı geçiyorsa path $ oluyor ve hiçbir şey olmuyor. Geçmiyorsa json_extract geçersiz bir path alıyor ve SQLite bad JSON path: 'castra_write_check_failed' hatasını fırlatıyor. RAISE() yalnızca trigger'ların içinde çalışıyor; bu da mesajında benim seçtiğim bir işareti taşıyan, bulabildiğim tek hataydı. Bu sayede Castra bir policy ihlalini diğer veritabanı hatalarından ayırt edip 500 yerine 403 write_check_failed dönebiliyor.

Burada sıra önemli. Önce input'u doğrulayıp sonra yazarsanız, diskteki satırın doğruladığınız satır olmadığı bir zaman aralığı oluşuyor. Kaydedilmiş satırı aynı batch içinde kontrol etmek o aralığı kapatıyor.

Sahiplik taklidi de aynı yoldan geçiyor. Kuralı owner("author_id") olan bir tabloda author_id örtük olarak server tarafından set ediliyor ve sunucu onu oturumdan dolduruyor. Client aynı değeri gönderirse sorun yok. Farklı bir değer gönderirse sessizce düzeltilmek yerine 403 alıyor, böylece taklit girişimi kaybolmak yerine görünür oluyor. Her iki durumda da kaydedilen değer payload'dan değil, oturumdan geliyor.

Alan kuralları bunun üzerine oturuyor. hidden kolonlar sunucudan hiç çıkmıyor, readonly kolonlara yazılamıyor, visibleTo ise bir kolonu belirli rollerle sınırlıyor. "self" de "isteğin kendi satırı" anlamına gelen bir sözde rol.

REST, MCP ve sizin route'larınız aynı motoru kullanıyor

REST ve OpenAPI, D1 şemanızdan ve policy'lerinizden üretiliyor. /openapi.json tam olarak açık olan işlemleri listeliyor. Kapalı bir tablo önce dokümante edilip sonra reddedilmiyor, belgede hiç yer almıyor.

En çok önemsediğim kısım MCP sunucusu. REST ile aynı motor ve aynı istek pipeline'ı üzerinde çalışan bir okuma ve yazma araç seti. MCP'ye özel ayrı bir veri yolu yok ve query_data ile ona karşılık gelen REST çağrısının bir kimlik matrisi boyunca aynı sonucu verdiğini kontrol eden bir test var.

  • describe_schema: tablolar, kolonlar, ilişkiler ve policy'ler
  • check_access: kimlik × işlem × satır için dry run; kararı ve eşleşen kuralı döndürüyor
  • explain_query: bir REST isteği alıyor, üretilen SQL'i ve uygulanan policy'leri döndürüyor
  • query_data: belirli bir kimlik olarak policy'ye bağlı okuma
  • create_data / update_data / delete_data: REST yazma yolundan geçen yazma işlemleri
  • propose_policy: castra.config.ts için şemaya ve tanımlı rollerinize göre doğrulanmış bir unified diff döndürüyor, ama asla uygulamıyor

Yani bir ajan "41 numaralı kullanıcı 9f3a satırını okuyabilir mi?" diye sorup eşleşen kuralı ve derlendiği WHERE ifadesini geri alabiliyor. Policy hakkındaki açıklamanıza güvenmek zorunda kalmıyor. Kendine bir admin yolu da uyduramıyor: uzak token'ların kapsamları var (schema:read, access:check, data:read, data:write), hiçbir kapsam admin yetkisi vermiyor ve uzaktan yapılan bir yazma her zaman token'ın kendi kullanıcısı olarak çalışıyor.

Her uzak çağrı _castra_audit tablosuna kaydediliyor. Argüman değerleri kaydedilmeden önce maskeleniyor. Bir yazma işlemi önce bir attempt satırı kaydediyor; o insert başarısız olursa yazma hiç çalışmıyor. Saklama süresi isteğe bağlı ve bir üst sınırı var.

İlk kullanıcı benim

Bu blogun arkasındaki API, api.mehmetozer.dev adresinde Castra üzerinde çalışıyor: tek Worker, tek D1, dört migration.

posts: {
  // Anyone, including anonymous, reads published posts; admins also read drafts.
  read: isPublic().where((q) => q.eb("is_published", "=", 1)).or(role("admin")),
  create: role("admin"),
  update: role("admin"),
  delete: role("admin"),
  filterable: ["lang", "slug", "is_published", "published_at"],
}

Anonim okuyucular yayınlanmış yazıları görüyor, ben taslakları da görüyorum, başka kimse yazamıyor. posts tablosu şu an okuduğunuz Markdown'u tutuyor. Onu tek bir POST isteği ve bir admin API anahtarıyla yayınladım.

Gerçekten kullanmaya başlayınca iki şey beni şaşırttı.

Birincisi, şema kayması (drift) bir deploy hatasına dönüştü. Deploy, commit edilmiş bir JSON snapshot'ını canlı veritabanıyla karşılaştırıyor ve uyuşmazlarsa yayına çıkmayı reddediyor. Böylece artık var olmayan bir kolona işaret eden bir kural production'a hiç ulaşmıyor, deploy daha önce duruyor.

İkincisi, sıkıcı kısımlar en işe yarayanlar çıktı. castra deploy sırasıyla provision → migrate → drift check → secret → wrangler deploy adımlarını çalıştırıyor ve tekrar tekrar güvenle çalıştırılabiliyor. castra doctor --remote token izinlerini, binding'leri, paket sürümlerini ve uzak drift'i kontrol edip eksikleri söylüyor. castra rollback, kayıtlı şema ve config parmak izleri canlı veritabanıyla uyuşmayan bir Worker sürümüne trafik yönlendirmeyi reddediyor. Bunların hiçbiri zekice değil, ama sistemi gönül rahatlığıyla çalışır halde bırakabilmemi sağlayan şey bunlar.

Birkaç rakam: v0.3.0 release kontrolünde 1.288 test çalıştı, buna ek olarak 529 allow/deny policy senaryosu ayrı bir koşuda çalıştı. Tasarımı değiştiren her karar için bir tane olmak üzere 85 mimari karar kaydı (ADR) var.

Bir konuda dürüst olmalıyım. npm'de 0.3.0 var ama bu yazının arkasındaki API hâlâ 0.2.1'de. Yeni sürüm çıktı diye değil, bir sebebim olduğunda yükselteceğim.

Ne olmadığı

Bunları gece 2'de kendiniz keşfetmektense burada okumanızı tercih ederim.

  • D1'in limitleri Castra'nın da limitleri: Workers Paid'de veritabanı başına 10 GB, Free'de 500 MB; sorgu başına 100 bağlı parametre; Worker çağrısı başına Paid'de 1.000 sorgu (Free'de 50).
  • Yazmalar tek bir primary'ye gidiyor. Cloudflare her yazma sorgusunu tasarım gereği oraya yönlendiriyor, bu yüzden Castra yoğun yazma yükü olan işler için uygun değil.
  • İnteraktif transaction ve istekler arası satır kilidi yok. Atomiklik tek bir batch() ile sınırlı, her korumalı yazmanın tam olarak bir batch olmasının sebebi de bu. Her biri önce okuyup sonra yazan iki istek birbirinin arasına girebilir.
  • Read replication'ı yapılandırmadım ve test etmedim. Kendiniz açarsanız, iptal edilmiş bir oturum ya da rol henüz güncellenmemiş bir replikadan hâlâ sunulabilir.
  • Rate limit'ler yalnızca yazmaları kapsıyor: sign-in, sign-up, /mcp ve tablo yazmaları, IP başına, auth için ayrı. Okumalar için bir WAF kuralı gerekiyor, dokümanlarda bir tane hazır var.
  • Multi-tenancy yok. Her deployment için tek Worker ve tek D1.
  • v1.0'a kadar kapsam dışı olanlar: PostgreSQL/MySQL/Hyperdrive, realtime (Durable Objects), kuyruklar, Redis/S3/OIDC adaptörleri ve her türlü hosted ya da yönetilen sürüm.

docs/SECURITY.md içinde gerçek bir bilinen sorunlar listesi de var. En sevdiğim madde şu: constraint ihlalleri bir existence oracle gibi çalışabiliyor. Bir unique-constraint 409 yanıtı, çağırana okuyamadığı bir satırın var olduğunu söyleyebiliyor. Bunu saklamak yerine yazdım.

Deneyin

npm create @castra-kit@latest my-app
cd my-app
cp .dev.vars.example .dev.vars    # BETTER_AUTH_SECRET: openssl rand -base64 32
pnpm db:migrate:local
pnpm dev                          # http://localhost:8787/health

Kendi hesabınıza deploy etmek tek bir komut ve bir Cloudflare API token'ı alıyor. Token için gereken minimum izinler repo'da listeli.

Sırada ne var

Sıradaki işler R2 depolama route'ları, çok seviyeli include'lar ve toplu işlemler. Multi-tenancy konusunda hâlâ karar vermedim. Deployment başına tek Worker ve tek D1, kişisel bir backend için temiz bir varsayılan, ama tenant'ları olan bir ürün için yanlış bir varsayım. Plugin SDK'sı, custom hook'lar ve kararlı bir config API'si v1.0'a ait.

D1 üzerinde bir şey geliştiriyorsanız ve kendi "bu kullanıcı bu satırı okuyabilir mi" kontrolünüzü yazdıysanız, nasıl yaptığınızı duymak isterim.