Güvenlik Loglaması: Kim, Neye, Hangi Kararla Erişti
Yetki kararları gözlemlenebilir olmalı: aktör, tenant, obje, aksiyon ve policy kararını kaydeden olay şeması ve test yaklaşımı.
Güvenlik Loglaması: Kim, Neye, Hangi Kararla Erişti#
Yetki kontrolü kodun içinde durur, ama kararın izi logda durur. Bir istek geldi, bir policy çalıştı, bir karar verildi ve bir obje okundu ya da yazıldı. Bu zincirin sadece sonucu değil, kararın kendisi de kayıt altında olmalıdır.
Aksi halde olay sonrası analiz şu soruda takılır: bu erişim gerçekten kontrol edildi mi, yoksa kontrolden mi geçti. Bu yazı, yetki kararlarını gözlemlenebilir yapan küçük bir olay şemasını anlatır. Aktör, tenant, obje, aksiyon ve karar aynı kayıtta toplanır. İzin verilen ve reddedilen istekler aynı formatla yazılır. Loga neyin girmeyeceği de şemanın parçasıdır.
Olaydan sonra sorulan sorular#
Bir olay yaşandığında ilk sorular her zaman aynıdır. Kim bu objeye dokundu. Hangi tenant bağlamında dokundu.
Okuma mı yaptı, yazma mı yaptı. Hangi policy, hangi sürümüyle bu karara izin verdi ya da reddetti. Bu soruların cevabı uygulama veritabanında yoktur. Veritabanı objenin son halini tutar, erişim kararlarının geçmişini tutmaz. Erişim logu yoksa geriye sadece tahmin kalır.
Hangi sorular cevapsız kalır#
Standart erişim logları genelde URL ve durum kodu tutar. Bu bilgi yetki sorularına cevap vermez. /invoices/abc adresine gelen 200 cevabı, hangi tenant filtresinin uygulandığını söylemez.
Aynı URL'ye gelen 403 cevabı, hangi rol kontrolünün başarısız olduğunu söylemez. Request kimliği yoksa, frontend hatası ile backend kararı eşleştirilemez. Policy adı ve sürümü yoksa, hangi kuralın çalıştığı bilinemez. Bu boşluklar, API yetkilendirme hataları yazısındaki sessiz açıklarla aynı kökten gelir. Kontrol kodda vardır ama kararı sonradan doğrulayacak kayıt yoktur.
Olay sonrası ekip şu tabloyla karşılaşır ve kaynağa göre neyin eksik kaldığını görür:
| Soru | Uygulama veritabanı | Standart HTTP logu | Yetki olayı logu |
|---|---|---|---|
| Kim erişti | Kısmen, son değiştiren alanı varsa | IP ve user agent | Kullanıcı kimliği ve oturum kimliği |
| Hangi tenant bağlamında | Objenin tenant alanı | Yok | İsteğin tenant kimliği ve objenin tenant kimliği |
| Hangi objeye erişildi | Objenin güncel hali | URL yolu | Obje tipi ve obje kimliği |
| Hangi aksiyon istendi | Yok | HTTP metodu | read, write ya da admin gibi policy aksiyonu |
| Karar ne oldu | Yok | Durum kodu | allow ya da deny kararı |
| Hangi kural çalıştı | Yok | Yok | Policy adı ve sürümü |
| İstekler nasıl birleşir | Yok | Zaman damgası tahmini | Ortak istek kimliği |
| Tabloyu soldan sağa okumak, kaydın neden ayrı bir olay tipine ihtiyacı olduğunu gösterir. Veritabanı durumu anlatır, HTTP logu trafiği anlatır, yetki olayı kararı anlatır. Karar kaydı olmadan diğer ikisi birleşmez. |
İzin verilen de yazılır reddedilen de#
Sadece reddedilen istekleri loglamak yaygın bir eksiktir. Gerekçe genelde hacim kaygısıdır: izin verilen istek çoktur, log şişer. Ama izin verilen kaydı olmadan şu sorular cevaplanamaz.
Bu obje gerçekten hangi kararla açıldı. Aynı kullanıcı bu objeye daha önce hangi aksiyonla erişti. Tenant filtresi o gün devrede miydi.
Reddedilen kaydı olmadan da saldırı sinyali kaybolur. Deneme yapan aktör görünmez olur. Bu yüzden kural basittir: her yetki kararı bir olay üretir.
Karar allow da olsa deny de olsa format aynıdır. Fark sadece decision alanındadır. Bu simetri, test yazımını da kolaylaştırır.
Test, izin verilen senaryoda allow olayı bekler. Reddedilen senaryoda deny olayı bekler. Biçim değişmediği için iki test de aynı doğrulama fonksiyonunu kullanır.
Karar policy katmanında verilir log da orada yazılır#
Yetki kararı dağınık yazılırsa log da dağınık olur. Her action içine serpiştirilmiş if kontrolleri, log çağrılarını da dağıtır. Biri unutulur, biri farklı format yazar, biri hiç yazmaz.
Çözüm, kararı tek fonksiyonda toplamaktır. Policy fonksiyonu kararı verir ve olay kaydını üretir. Action fonksiyonu sadece policy sonucunu kullanır.
Bu ayrım, log bütünlüğünü kod yapısıyla güvence altına alır. Yeni bir action eklendiğinde geliştirici log formatını düşünmez. Policy fonksiyonunu çağırır, log otomatik gelir. Format değiştiğinde tek yer güncellenir.
Olay şeması ve tek yardımcı#
Şema küçük tutulur, ama her alanın görevi bellidir.
TypeScript olay tipi#
Aşağıdaki tip, kanonik alanları kilitler. Opsiyonel alanlar ayrı bir detay objesinde taşınır, ana alanlar her olayda zorunludur.
// lib/authz/authz-log.ts export type AuthzDecision = 'allow' | 'deny'; export type AuthzAction = 'read' | 'write' | 'admin'; export interface AuthzEvent { timestamp: string; actorId: string; sessionId: string; tenantId: string; objectType: 'invoice' | 'project' | 'document' | 'member'; objectId: string; objectTenantId: string; action: AuthzAction; decision: AuthzDecision; policyName: string; policyVersion: string; requestId: string; denyReason?: string; latencyMs?: number; }
denyReason sadece ret olaylarında dolar. İzin olaylarında boş bırakılır, böylece izin kayıtları küçük kalır. latencyMs policy kontrolünün maliyetini gösterir. Yavaşlayan sorgular bu alanla fark edilir.
Tek kanonik yardımcı#
Formatı korumak için tek yazım noktası olur. Uygulamadaki her policy bu yardımcıyı çağırır. Yardımcı, eksik alanla çağrılmaya izin vermez.
// lib/authz/authz-log.ts import { authzLogger } from './authz-logger'; export function logAuthzEvent(event: AuthzEvent): void { authzLogger.info({ type: 'authz.decision', timestamp: event.timestamp, actorId: event.actorId, sessionId: event.sessionId, tenantId: event.tenantId, objectType: event.objectType, objectId: event.objectId, objectTenantId: event.objectTenantId, action: event.action, decision: event.decision, policyName: event.policyName, policyVersion: event.policyVersion, requestId: event.requestId, denyReason: event.denyReason, latencyMs: event.latencyMs, }); } export function buildAuthzTimestamp(now: Date = new Date()): string { return now.toISOString(); }
type alanı sabit bir dizedir. Bu sabit, log arama sorgularını basit tutar. Arama type:authz.decision ile başlar, sonra decision:deny gibi filtreler eklenir.
Yardımcı, log taşıma detayını gizler. Altında hangi log kütüphanesi olduğu çağıranı ilgilendirmez. JSON satır formatı kullanılır, böylece her satır bağımsız parse edilir.
Policy katmanından çağrı#
Policy fonksiyonu hem kararı verir hem kaydı yazar. Başarı ve başarısızlık yollarının ikisi de log üretir. Erken dönüşlerin her biri kendi olayını yazar.
// lib/actions/policy.ts import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { buildAuthzTimestamp, logAuthzEvent } from '@/lib/authz/authz-log'; const POLICY_NAME = 'invoice-access'; const POLICY_VERSION = 'v3'; export async function requireInvoiceAccess( invoiceId: string, action: 'read' | 'write', requestId: string, ) { const startedAt = Date.now(); const session = await auth(); if (!session) { logAuthzEvent({ timestamp: buildAuthzTimestamp(), actorId: 'anonymous', sessionId: 'none', tenantId: 'unknown', objectType: 'invoice', objectId: invoiceId, objectTenantId: 'unknown', action, decision: 'deny', policyName: POLICY_NAME, policyVersion: POLICY_VERSION, requestId, denyReason: 'no-session', latencyMs: Date.now() - startedAt, }); throw new Error('Not found'); } const invoice = await db.invoice.findFirst({ where: { id: invoiceId, }, select: { id: true, tenantId: true, }, }); if (!invoice) { logAuthzEvent({ timestamp: buildAuthzTimestamp(), actorId: session.user.id, sessionId: session.sessionId, tenantId: session.tenantId, objectType: 'invoice', objectId: invoiceId, objectTenantId: 'unknown', action, decision: 'deny', policyName: POLICY_NAME, policyVersion: POLICY_VERSION, requestId, denyReason: 'object-not-found-or-hidden', latencyMs: Date.now() - startedAt, }); throw new Error('Not found'); } if (invoice.tenantId !== session.tenantId) { logAuthzEvent({ timestamp: buildAuthzTimestamp(), actorId: session.user.id, sessionId: session.sessionId, tenantId: session.tenantId, objectType: 'invoice', objectId: invoice.id, objectTenantId: invoice.tenantId, action, decision: 'deny', policyName: POLICY_NAME, policyVersion: POLICY_VERSION, requestId, denyReason: 'tenant-mismatch', latencyMs: Date.now() - startedAt, }); throw new Error('Not found'); } const membership = await db.membership.findFirst({ where: { userId: session.user.id, tenantId: session.tenantId, }, select: { role: true, }, }); const allowedRoles = action === 'write' ? ['owner', 'finance'] : ['owner', 'finance', 'viewer']; if (!membership || !allowedRoles.includes(membership.role)) { logAuthzEvent({ timestamp: buildAuthzTimestamp(), actorId: session.user.id, sessionId: session.sessionId, tenantId: session.tenantId, objectType: 'invoice', objectId: invoice.id, objectTenantId: invoice.tenantId, action, decision: 'deny', policyName: POLICY_NAME, policyVersion: POLICY_VERSION, requestId, denyReason: 'insufficient-role', latencyMs: Date.now() - startedAt, }); throw new Error('Not found'); } logAuthzEvent({ timestamp: buildAuthzTimestamp(), actorId: session.user.id, sessionId: session.sessionId, tenantId: session.tenantId, objectType: 'invoice', objectId: invoice.id, objectTenantId: invoice.tenantId, action, decision: 'allow', policyName: POLICY_NAME, policyVersion: POLICY_VERSION, requestId, latencyMs: Date.now() - startedAt, }); return { invoice, session }; }
Bu yapıdaki her ret aynı hatayı döndürür. Dışarıdan bakınca yetkisiz kaynak ile var olmayan kaynak ayırt edilemez. Logun içinde ise ret nedeni açıkça yazılır.
Dışa kapalı, içe açık davranışı bu ayrımla kurulur. Bu desen, Server Actions yetkilendirme yazısındaki tek policy fikrinin devamıdır. Karar tek yerde verilir, kayıt tek formatla yazılır.
Alan sözlüğü#
Şema tablosu, alanların beklenen değerlerini sabitler. Yeni katılan biri bu tabloya bakarak log okuyabilir.
| Alan | Zorunlu mu | Örnek değer | Açıklama |
|---|---|---|---|
| timestamp | Evet | 2026-10-05T08:12:31.042Z | ISO formatında olay zamanı |
| actorId | Evet | user_91 | Kararı tetikleyen kullanıcı kimliği |
| sessionId | Evet | sess_44 | Aynı oturumun denemelerini birleştirir |
| tenantId | Evet | tenant_acme | İsteğin tenant bağlamı |
| objectType | Evet | invoice | Erişilmek istenen kayıt tipi |
| objectId | Evet | inv_10021 | Erişilmek istenen kayıt kimliği |
| objectTenantId | Evet | tenant_acme | Kaydın gerçek sahibi tenant |
| action | Evet | read | İstenen işlem seviyesi |
| decision | Evet | allow | Policy sonucu |
| policyName | Evet | invoice-access | Çalışan kuralın adı |
| policyVersion | Evet | v3 | Kuralın sürümü |
| requestId | Evet | req_7f3a | İstek boyunca taşınan ilişki kimliği |
| denyReason | Ret olayında evet | tenant-mismatch | Ret neden kodu |
| latencyMs | Hayır | 14 | Policy kontrol süresi |
Ret nedeni için kapalı bir kelime listesi kullanılır. Serbest metin yazılmaz, kod yazılır. Kod listesi küçüktür: no-session, object-not-found-or-hidden, tenant-mismatch, insufficient-role. Kapalı liste, arama ve sayımı kolaylaştırır. Yeni bir neden gerekiyorsa listeye eklenir ve policy sürümü artırılır. |
Kayda girmeyenler#
Güvenlik logu her şeyi yazmaz. Bazı veriler loga girerse logun kendisi risk olur. Bu bölüm şemanın sınırını çizer.
Sırlar ve jetonlar asla yazılmaz#
Parola, API anahtarı, oturum jetonu ve imza değerleri loga yazılmaz. Yetki kararı bu değerlere bakılarak verilir, ama değerlerin kendisi kayda girmez. Hata ayıklama kolaylığı bu kuralı esnetmez.
Jetonu loga yazmak, log erişimi olan herkese oturum devri imkânı verir. Bu yüzden yardımcı fonksiyon, sır alanlarını kabul etmez. Tip buna izin vermediği için yanlışlıkla yazmak da zorlaşır.
İstek gövdesinin tamamını loglamak da aynı riski taşır. Gövde içinde parola ya da jeton olabilir. Sadece karar için gereken kimlikler yazılır, gövdenin geri kalanı yazılmaz.
PII en aza indirilir#
Kişisel veri ihtiyacı kimlikle sınırlıdır. Kullanıcı kimliği yazılır, kullanıcı profili yazılmaz. E-posta, telefon, adres gibi alanlar yetki olayında yer almaz.
Fatura içeriği, belge metni ve dosya adı da kayda girmez. Kayıt, içeriği değil erişimi anlatır. Bu ayrım amaç sınırlamasının doğal sonucudur.
Logun amacı erişim kararlarını denetlemektir. Bu amaç için obje kimliği yeterlidir, obje içeriği gerekmez. İçerik gerektiğinde veritabanından, ayrı bir izinle bakılır.
Saklama amacı ve süresi yazılır#
Her logun bir amacı ve bir ömrü vardır. Yetki logunun amacı kayıt altına yazılır: erişim kararlarını sonradan doğrulamak ve saldırı sinyallerini görmek. Bu amaç dışındaki kullanımlar aynı veri için ayrı değerlendirme ister.
Örneğin ürün analitiği için yetki logunu kullanmak amaç genişlemesidir. Böyle bir ihtiyaç varsa ayrı bir olay tipi ve ayrı bir onay gerekir. Saklama süresi de baştan belirlenir.
Sıcak depoda kısa süre, ılık depoda daha uzun süre tutulur. Süre dolan kayıt silinir ya da anonimleştirilir. Bu kural, log hacmi kadar veri koruma için de önemlidir. Az veri, kısa süre, sınırlı erişim üçlüsü birlikte uygulanır.
Aşağıdaki tablo hangi verinin yazılacağına karar vermeyi kolaylaştırır:
| Veri | Yetki olayına yazılır mı | Neden |
|---|---|---|
| Kullanıcı kimliği | Evet | Aktör sayımı için gerekli |
| Tenant kimliği | Evet | İzolasyon denetimi için gerekli |
| Obje kimliği | Evet | Hangi kayda dokunulduğu için gerekli |
| Rol adı | Hayır, ret kodu yeterli | Ret nedeni kodla anlatılır |
| E-posta adresi | Hayır | Kimlik için kimlik kodu yeterlidir |
| Parola ve jeton | Asla | Sızıntı riskini büyütür |
| Fatura satırları | Hayır | İçerik denetim için gerekmez |
| İstek gövdesi | Hayır | Sır ve kişisel veri taşıyabilir |
| Tablo, kod incelemesinde hızlı bir kontrol listesi olarak kullanılır. Yeni bir alan eklenmek istendiğinde ilk soru aynıdır: bu alan kararın doğrulanması için gerekli mi. Gerekli değilse şemaya eklenmez. |
Reddedilen istekler saldırı sinyalidir#
Ret kayıtları hata ayıklama çıktısı değildir. Peş peşe gelen retler, birinin kapıları yokladığını gösterebilir. Tek bir ret normaldir: yanlış link, süresi dolmuş paylaşım, değişmiş rol. Aynı aktörden kısa sürede gelen çok sayıda ret normal değildir.
Saldırı örüntüleri#
Ret loglarında aranan örüntüler bellidir. Her örüntü farklı bir yoklama davranışını anlatır.
| Örüntü | Logda görünümü | Olası anlam |
|---|---|---|
| Artan obje kimliği taraması | Aynı aktör, aynı obje tipi, farklı kimlikler, kısa aralık | Kayıt numaraları deneniyor |
| Çapraz tenant yoklama | Aynı aktör, farklı objectTenantId, çok sayıda tenant-mismatch | Başka tenant kayıtları deneniyor |
| Rol yükseltme denemesi | Aynı aktör, aynı obje, read izinli ama write reddedilmiş, tekrarlayan write | Yazma yetkisi zorlanıyor |
| Paylaşım linki denemesi | Farklı objeler, object-not-found-or-hidden yoğunluğu | Tahmin edilen kimlikler deneniyor |
| Oturumsuz yoklama | anonymous aktörden aynı tipe yoğun istek | Girişsiz erişim aranıyor |
| Gece yoğunluğu | Normal saatler dışında ret artışı | Otomatik deneme olabilir |
| Bu tablo tek başına hüküm vermez. Her satır incelemeye değer bir aday üretir. Kesin yargı, isteğin bağlamıyla birlikte kurulur. Örneğin destek ekibi gece vardiyasında çalışıyorsa gece yoğunluğu normal olabilir. Bu yüzden eşikler ortama göre ayarlanır. |
Eşik tasarımı#
Uyarı kuralı üç parçadan oluşur. Sayaç, pencere ve eylem. Sayaç hangi olayların sayılacağını söyler. Pencere sayımın hangi sürede yapılacağını söyler. Eylem eşik aşılınca ne olacağını söyler.
// lib/authz/authz-alerts.ts export interface DenyThreshold { name: string; windowSeconds: number; maxDenies: number; groupBy: 'actorId' | 'sessionId' | 'objectType'; denyReasons: Array<string>; } export const DENY_THRESHOLDS: Array<DenyThreshold> = [ { name: 'actor-probing', windowSeconds: 300, maxDenies: 20, groupBy: 'actorId', denyReasons: ['tenant-mismatch', 'object-not-found-or-hidden'], }, { name: 'write-escalation', windowSeconds: 600, maxDenies: 10, groupBy: 'actorId', denyReasons: ['insufficient-role'], }, { name: 'anonymous-scan', windowSeconds: 300, maxDenies: 30, groupBy: 'sessionId', denyReasons: ['no-session', 'object-not-found-or-hidden'], }, ];
Sayaç fonksiyonu, aynı gruplama anahtarı için retleri sayar. Pencere dolunca sayaç sıfırlanır. Eşik aşılınca uyarı üretilir, istek otomatik engellenmez. Otomatik engelleme ayrı bir karardır, log kuralı tek başına engellemez.
// lib/authz/authz-alerts.ts export function isThresholdExceeded( events: Array<{ timestamp: string; denyReason?: string }>, threshold: DenyThreshold, now: Date = new Date(), ): boolean { const windowStart = now.getTime() - threshold.windowSeconds * 1000; let count = 0; for (const event of events) { const eventTime = Date.parse(event.timestamp); if (Number.isNaN(eventTime)) { continue; } if (eventTime < windowStart) { continue; } if (event.denyReason && threshold.denyReasons.includes(event.denyReason)) { count += 1; } } return count >= threshold.maxDenies; }
Bu fonksiyon saf tutulur, dış bağımlılığı yoktur. Girdi olarak olay listesi alır, çıktı olarak eşik kararını verir. Testi kolaydır, saati parametre alır. Üretimde olay listesi log deposundan okunur.
Yanlış uyarılara karşı ayar#
Eşikler ilk değerleriyle bırakılmaz. İlk hafta gözlem yapılır, uyarı sayılır, gerçek yoklama ayrılır. Yanlış uyarı kaynakları genelde aynıdır.
Hatalı kaydedilmiş yer imleri, aynı kayda tekrar tekrar ret üretir. Rolü değişmiş bir kullanıcı eski sayfayı yeniler, ret serisi oluşur. Destek ekibi müşteri adına kayıt açmaya çalışır, tenant uyumsuzluğu üretir.
Mobil uygulama eski sürümü eski aksiyonu çağırır, rol reti üretir. Bu durumlar için üç ayar kullanılır. Beyaz liste: bilinen servis hesapları ayrı eşikle izlenir.
Kademeli eşik: önce bilgi uyarısı, sonra inceleme uyarısı üretilir. Bağlam zenginleştirme: uyarıya son beş objenin tipi ve ret kodu eklenir. Uyarı metni kısa tutulur, ama incelemeye yetecek alanı taşır. Aktör, pencere, ret sayısı, baskın ret kodu ve örnek requestId değerleri yeterlidir. İnceleyen kişi bu bilgilerle log deposunda derinleşir.
Saklama ve erişim#
Yetki logu ayrı bir veri deposunda tutulur. Uygulama veritabanıyla aynı tabloda tutulmaz. Ayrı deponun iki nedeni vardır. Biri erişim ayrımıdır, diğeri değişime karşı korumadır.
Kim okuyabilir#
Okuma erişimi dar tutulur. Güvenlik incelemesi yapan küçük bir grup okur. Ürün ekibi toplu sayımları görebilir, tekil aktör kayıtlarını göremez.
Destek ekibi sadece belirli bir requestId ile kayıt görebilir, tarama yapamaz. Bu ayrım teknik olarak da zorlanır. Log deposunda rol bazlı erişim olur, herkes aynı sorguyu çalıştıramaz.
Üretim verisine erişen kişi sayısı azaldıkça sızıntı yüzeyi küçülür. Erişimlerin kendisi de kaydedilir. Logu kimin okuduğu, hangi sorguyu çalıştırdığı ayrı bir iz bırakır. Bu iz, yetki logundan ayrı tutulur, karışmaz.
Sıcak ve ılık katman#
Saklama iki katmanlı kurulur. Sıcak katman hızlı arama içindir, kısa süre tutar. Ilık katman geriye dönük inceleme içindir, daha uzun tutar.
| Katman | Amaç | Tipik süre | Erişim hızı |
|---|---|---|---|
| Sıcak | Uyarı ve ilk inceleme | Kısa, gün mertebesi | Saniyeler içinde sorgu |
| Ilık | Olay sonrası derin analiz | Daha uzun, ay mertebesi | Dakikalar içinde sorgu |
| Arşiv | Hukuki ve denetim ihtiyacı | Politika ile belirlenir | Yavaş, taleple açılır |
| Süreler politikaya göre seçilir, bu yazı sayı vermez. Önemli olan katman ayrımının baştan kurulmasıdır. Her şey sıcakta tutulursa maliyet büyür. |
Her şey arşivde tutulursa uyarı çalışmaz. Geçiş kuralı otomatiktir. Süresi dolan kayıt sıcak katmandan ılık katmana taşınır. Ilık katmanda süresi dolan kayıt silinir ya da kimlik alanları maskelenir. Silme ve maskeleme işlemleri de kayıt altına alınır.
Değişime karşı koruma#
Yetki logu yalnızca eklenir. Var olan satır güncellenmez, silinmez. Düzeltme gerekiyorsa yeni bir satır yazılır, eski satır yerinde kalır.
Depo ayrı tutulur, uygulamayla aynı yazma iznine sahip değildir. Uygulama sadece ekleme iznine sahiptir, okuma ve silme izni uygulamaya verilmez. Bu ayrım, bir hesap ele geçirilse bile logun topluca değiştirilmesini zorlaştırır.
Bütünlük için basit kontroller yeterlidir. Her satıra zincir özeti eklenebilir, ya da depo seviyesinde değişmezlik açılabilir. Amaç adli bilişim seviyesinde kanıt değildir.
Amaç, sıradan bir hatanın ya da tek bir hesabın logu sessizce değiştirmesini engellemektir. Yedekler de aynı kurala uyar. Yedek geri yükleme, mevcut logun üzerine yazmaz. Geri yükleme ayrı bir alana yapılır, birleştirme kuralları açık yazılır.
Logların testi#
Log iddiası testle korunur. Test yoksa format zamanla bozulur. Biri yeni bir ret yolu ekler, log çağrısını unutur. Biri hata mesajını değiştirir, ret kodunu serbest metne çevirir. Matris testleri bu bozulmayı yakalar.
Ret olayı üretildi mi#
Aşağıdaki yardımcı, bellek içi log deposunu okur. Test, işlemi çalıştırır, sonra depodaki olayı doğrular.
// test/authz-log-assert.ts import type { AuthzEvent } from '@/lib/authz/authz-log'; export function findAuthzEvents( events: Array<AuthzEvent>, filter: Partial<AuthzEvent>, ): Array<AuthzEvent> { return events.filter((event) => { for (const [key, value] of Object.entries(filter)) { if ((event as Record<string, unknown>)[key] !== value) { return false; } } return true; }); } export function expectDenyLogged( events: Array<AuthzEvent>, expected: { actorId: string; tenantId: string; objectId: string; action: 'read' | 'write' | 'admin'; denyReason: string; requestId: string; }, ): void { const matches = findAuthzEvents(events, { actorId: expected.actorId, tenantId: expected.tenantId, objectId: expected.objectId, action: expected.action, decision: 'deny', requestId: expected.requestId, }); if (matches.length !== 1) { throw new Error( `Expected one deny event, found ${matches.length} for request ${expected.requestId}`, ); } if (matches[0].denyReason !== expected.denyReason) { throw new Error( `Expected deny reason ${expected.denyReason}, found ${matches[0].denyReason}`, ); } } export function expectAllowLogged( events: Array<AuthzEvent>, expected: { actorId: string; tenantId: string; objectId: string; objectTenantId: string; action: 'read' | 'write' | 'admin'; requestId: string; }, ): void { const matches = findAuthzEvents(events, { actorId: expected.actorId, tenantId: expected.tenantId, objectId: expected.objectId, action: expected.action, decision: 'allow', requestId: expected.requestId, }); if (matches.length !== 1) { throw new Error( `Expected one allow event, found ${matches.length} for request ${expected.requestId}`, ); } if (matches[0].objectTenantId !== expected.objectTenantId) { throw new Error('Allow event tenant does not match object tenant'); } if (matches[0].tenantId !== matches[0].objectTenantId) { throw new Error('Allow event shows cross-tenant access'); } }
expectDenyLogged üç şeyi doğrular. Doğru aktör, doğru obje ve doğru ret kodu aynı olayda buluştu. Olay sayısı tam birdir, ne sıfır ne iki.
expectAllowLogged iki ek kontrol yapar. Objenin tenant kimliği beklenen değerle eşleşir. İstek tenant kimliği ile obje tenant kimliği aynıdır. Bu iki kontrol, çapraz tenant iznin logda görünmeden geçmesini engeller.
Matris testine log doğrulaması eklenir#
Yetki matrisi zaten erişim sonucunu test eder. Aynı matrise log doğrulaması eklenir, ayrı bir test dosyası açılmaz. Her satır hem HTTP ya da fonksiyon sonucunu hem log olayını doğrular.
// test/invoice-authz-matrix.test.ts import { requireInvoiceAccess } from '@/lib/actions/policy'; import { getTestEvents, clearTestEvents } from '@/test/test-logger'; import { expectAllowLogged, expectDenyLogged } from '@/test/authz-log-assert'; describe('invoice authorization matrix with log assertions', () => { beforeEach(() => { clearTestEvents(); }); it('owner can read own-tenant invoice and logs allow', async () => { const requestId = 'req-matrix-001'; await requireInvoiceAccess('inv_10021', 'read', requestId); expectAllowLogged(getTestEvents(), { actorId: 'user_owner_1', tenantId: 'tenant_acme', objectId: 'inv_10021', objectTenantId: 'tenant_acme', action: 'read', requestId, }); }); it('viewer cannot write and logs insufficient-role', async () => { const requestId = 'req-matrix-002'; await expect( requireInvoiceAccess('inv_10021', 'write', requestId), ).rejects.toThrow('Not found'); expectDenyLogged(getTestEvents(), { actorId: 'user_viewer_1', tenantId: 'tenant_acme', objectId: 'inv_10021', action: 'write', denyReason: 'insufficient-role', requestId, }); }); it('member of tenant A cannot read tenant B invoice', async () => { const requestId = 'req-matrix-003'; await expect( requireInvoiceAccess('inv_90001', 'read', requestId), ).rejects.toThrow('Not found'); expectDenyLogged(getTestEvents(), { actorId: 'user_owner_1', tenantId: 'tenant_acme', objectId: 'inv_90001', action: 'read', denyReason: 'tenant-mismatch', requestId, }); }); it('unknown invoice logs hidden-object deny without leaking', async () => { const requestId = 'req-matrix-004'; await expect( requireInvoiceAccess('inv_missing', 'read', requestId), ).rejects.toThrow('Not found'); expectDenyLogged(getTestEvents(), { actorId: 'user_owner_1', tenantId: 'tenant_acme', objectId: 'inv_missing', action: 'read', denyReason: 'object-not-found-or-hidden', requestId, }); }); });
Matris büyüdükçe bu desen aynı kalır. Her satırda istek kimliği farklıdır, böylece olaylar karışmaz. Başarısız sonuçta hata mesajı dışarı bilgi vermez, ama log içerde ret kodunu taşır.
Bu ayrım özellikle BOLA testlerinde önemlidir. Bu yaklaşım, BOLA test matrisi otomasyonu yazısındaki matris fikrini log katmanına taşır. Erişim sonucu ve log olayı aynı testte doğrulanır.
Log şeması testi#
Formatın kendisi de test edilir. Şema testi, zorunlu alanların dolu olduğunu ve kapalı listelerin dışına çıkılmadığını doğrular.
// test/authz-log-schema.test.ts import type { AuthzEvent } from '@/lib/authz/authz-log'; const ALLOWED_REASONS = [ 'no-session', 'object-not-found-or-hidden', 'tenant-mismatch', 'insufficient-role', ]; export function assertAuthzSchema(event: AuthzEvent): void { const required = [ 'timestamp', 'actorId', 'sessionId', 'tenantId', 'objectType', 'objectId', 'objectTenantId', 'action', 'decision', 'policyName', 'policyVersion', 'requestId', ]; for (const field of required) { const value = (event as Record<string, unknown>)[field]; if (typeof value !== 'string' || value.length === 0) { throw new Error(`Authz event field ${field} is missing or empty`); } } if (Number.isNaN(Date.parse(event.timestamp))) { throw new Error('Authz event timestamp is not parseable'); } if (event.decision === 'deny') { if (!event.denyReason || !ALLOWED_REASONS.includes(event.denyReason)) { throw new Error('Deny event needs a known deny reason'); } } if (event.decision === 'allow' && event.denyReason) { throw new Error('Allow event must not carry a deny reason'); } }
Bu test her olayda çalışır. Yeni bir alan eklendiğinde test güncellenir. Serbest metin ret nedeni yazmak isteyen bu teste takılır.
Sınırlamalar#
Log her sorunu çözmez, kendi maliyetini de getirir. Bu sınırları bilmek, şemayı doğru yerde tutar.
Hacim maliyeti#
Her karar bir olay demektir, olay sayısı istek sayısıyla büyür. Okuma ağırlıklı bir üründe izin olayları baskın olur. Depolama ve sorgu maliyeti artar.
Bu maliyet üç yolla dengelenir. Sıcak katman kısa tutulur, eski kayıt ılık katmana taşınır. İzin olayları küçük tutulur, detay alanı eklenmez.
Yüksek hacimli salt okuma uçları için örnekleme ayrı değerlendirilir. Örnekleme kararı şemayı bozmaz, sadece bazı izin olaylarının yazılmaması anlamına gelir. Ama örnekleme, olay sonrası sorulara eksik cevap verir. Bu yüzden örnekleme varsa açıkça belgelenir ve ret olayları örnekleme dışında tutulur. Ret olaylarının tamamı yazılır, çünkü sinyal değeri yüksektir ve hacmi düşüktür.
Saat farkları#
Dağıtık servislerde saatler birebir aynı değildir. İki servisin yazdığı olayların sırası, milisaniye seviyesinde karışabilir. Bu karışıklık, timestamp alanına tek başına güvenmeyi zorlaştırır.
Çözüm, sırayı tek bir saate bağlamamak ve ilişkiyi requestId ile kurmaktır. Aynı isteğin olayları requestId ile birleşir, saate bakılmaz. Olaylar arası neden-sonuç ilişkisi bu kimlikle kurulur.
Saat eşleşmesi için altyapı seviyesinde zaman senkronizasyonu kullanılır. Uygulama kodu kendi saat düzeltmesini yapmaz. Log yardımcısı saati üretildiği anda yazar, geriye dönük düzeltme yapmaz.
Örnekleme dengesi#
Bazı ekipler hacmi kısmak için izin olaylarını örnekler. Örneğin her on izin olayından biri yazılır. Bu seçim sorgu maliyetini düşürür, ama denetim boşluğu açar.
Örneklenen logda şu sorular eksik kalır. Bu kullanıcı bu objeye bu hafta kaç kez erişti. Bu obje hangi kararlarla açıldı.
Tenant filtresi belirli bir gün devrede miydi. Bu yüzden örnekleme varsayılan değildir. Varsayılan, her kararın yazılmasıdır. Örnekleme ancak hacim ölçülüp belgelenirse açılır. Açılsa bile ret olayları, yönetici işlemleri ve çapraz tenant denemeleri her zaman yazılır.
| Sınırlama | Etkisi | Alınan önlem |
|---|---|---|
| Yüksek log hacmi | Depolama ve sorgu maliyeti | Kısa sıcak katman, küçük izin olayı |
| Saat farkı | Milisaniye sırasında karışma | Sıralamada requestId birleşimi |
| Örnekleme | Denetim boşluğu | Ret olayları her zaman yazılır |
| Sır sızıntısı riski | Logun kendisi hedef olur | Sır alanları şemaya alınmaz |
| Yanlış uyarı | İnceleme yükü | Kademeli eşik ve bağlam alanı |
| Tablo, bu tasarımın iddialarını sınırlar. Log kararın izidir, kararın kendisi değildir. Kayıt, policy kodunun yerini tutmaz, onu denetlenebilir yapar. |
Kısa sonuç#
Yetki kararı görünmez kaldıkça olay analizi tahmine dayanır. Küçük bir şema bu boşluğu kapatır: aktör, tenant, obje, aksiyon, karar, policy sürümü ve istek kimliği aynı satırda buluşur. Sırlar ve içerik dışarıda kalır, kimlikler ve karar içerde kalır. Ret kayıtları saldırı sinyaline dönüşür, izin kayıtları erişim geçmişine dönüşür. Test, her kararın doğru olayı ürettiğini doğrular.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver