---
title: "PostgreSQL ile Merkezi Audit Log: Kim, Ne Zaman, Neyi Değiştirdi?"
description: "Şirket vericisini kim bozdu? Kim, ne zaman, neyi değiştirdi? PostgreSQL trigger ve jsonb mimarisiyle tek fonksiyondan devasa Audit-Log (Denetim İzi)..."
date: 2026-04-06
category: guvenlik
tags: ["postgresql", "audit-log", "supabase", "trigger", "güvenlik", "denetim", "astaflow"]
url: https://mikroerp.dev/blog/postgresql-merkezi-audit-log-sistemi/
---

## İş Problemi: "O Ürünün Fiyatını Kim Artırdı?"

ERP veya B2B yönetim sistemlerinde patronların klasik bir fırçası vardır: 
*"Dün bu mamanın fiyatı 1500 TL idi, bugün sabah müşteri fişine 1900 TL'den basılmış, kim değiştirdi bunu?!"*

Eğer `urunler` tablonuzda sadece `fiyat` sütunu ve en fazla `updated_at` sütunu varsa patrona vereceğiniz cevap koca bir iç geçirmeden ibarettir. 
Geleneksel web geliştiriciler üşendikleri için veri tablosuna `updated_by_user` diye bir sütun ekler, işi çözerler. Fakat ya müşteri ürünün 10 gün önceki durumunu, geçmiş rotasını bilmek isterse? "Fiyat 1200'den 1500'e Cuma günü çıkmış, Pazartesi 1900 olmuş" demek istiyorsa? 

**Çözüm: Merkezi Loglama Tablosu (Audit Logging)**
Uygulamada birisi bir şeyi sildiğinde, değiştirdiğinde veya eklediğinde arka planda dedektif gibi bunu yazan bir veritabanı Trigger'ına ihtiyacınız vardır.

## Çözüm: Zekice Tasarlanmış JSONB Trigger (Supabase Özel)

Auditing (Denetim İzleme) yazmak çok teferruatlı bir iştir. Fiyatı değişen ürünün adını `AuditLog` tablosuna VARCHAR olarak yazarsanız esneklik kaybolur. PostgreSQL'in efsanevi **JSONB** veritipini kullanarak, o andaki tüm kaydı tek hücreye "Fotoğraf (Snapshot)" olarak hapsederiz.

Ve dahası Supabase kurgusunda `auth.uid()` yakalayarak "Hangi kullanıcı değiştirdi?" sorusunu da çözeriz:

### 1. Master Denetim Tablomuzu Oluşturun
```sql
CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    islem_tipi VARCHAR(10) NOT NULL,      -- INSERT / UPDATE / DELETE
    tablo_adi VARCHAR(50) NOT NULL,       -- Hangi tabloda oldu?
    kayit_id TEXT,                        -- Hangi ID'li satırla oynandı?
    islemi_yapan UUID,                    -- Supabase Auth User ID (Kim?)
    eski_veriler JSONB,                   -- İşlemden önceki durumun tam fotoğrafı
    yeni_veriler JSONB,                   -- İşlemden sonraki durumun tam fotoğrafı
    degisen_alanlar TEXT[],               -- Sadece değişmiş Kolon İsimleri (Array)
    islem_tarihi TIMESTAMPTZ DEFAULT NOW()
);
```

### 2. Akıllı PostgreSQL Trigger Fonksiyonu
Bu fonksiyon herhangi bir tablodaki değişikliği otomatik tarayıp JSON'a döker. (Supabase Supavisor mimarisiyle test edildi, çok stabildir).

```sql
CREATE OR REPLACE FUNCTION astaflow_global_auditor()
RETURNS TRIGGER AS $$
DECLARE
    degisenler TEXT[];
    key_val TEXT;
BEGIN
    -- SADECE DEĞİŞEN KOLONLARI BULMA ZEKASI (Performans için şarttır!)
    IF TG_OP = 'UPDATE' THEN
        FOR key_val IN SELECT jsonb_object_keys(to_jsonb(NEW))
        LOOP
            IF to_jsonb(OLD) ->> key_val IS DISTINCT FROM to_jsonb(NEW) ->> key_val THEN
                degisenler := array_append(degisenler, key_val);
            END IF;
        END LOOP;
        
        -- ÖNEMLİ: Eğer birisi sadece formda 'Kaydet'e bastı ama değerler aynıysa, log atma!
        degisenler := array_remove(degisenler, 'updated_at'); 
        IF degisenler IS NULL OR array_length(degisenler, 1) IS NULL THEN
            RETURN NEW; 
        END IF;
    END IF;

    -- Tabloya Kanıt Gönderiliyor
    INSERT INTO audit_log (
        islem_tipi, tablo_adi, kayit_id, islemi_yapan, 
        eski_veriler, yeni_veriler, degisen_alanlar
    ) VALUES (
        TG_OP, 
        TG_TABLE_NAME,
        CASE WHEN TG_OP = 'DELETE' THEN OLD.id::TEXT ELSE NEW.id::TEXT END,
        auth.uid(), -- Supabase sihirli kodu
        CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END,
        CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END,
        degisenler
    );
    
    RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```

### 3. İstediğiniz Tabloya Bağlama
```sql
CREATE TRIGGER t_urunler_audit
AFTER INSERT OR UPDATE OR DELETE ON siparis_kalemleri
FOR EACH ROW EXECUTE FUNCTION astaflow_global_auditor();
```

## Performans Raporlama (Supabase Dashboard Görüntüsü)

Sonucu incelediğinizde şöyle bir güzellik olur:

> `SELECT * FROM audit_log WHERE kayit_id = '5f4d...'`

**Sorgu Sonucu:**
*   **İşlem Tipi:** UPDATE
*   **Tablo:** siparis_kalemleri
*   **Yapan:** `4ba2... (Kasiyer Ayşe)`
*   **Eski Veri:** `{"fiyat": 1500, "miktar": 2, "odeme": "nakit"}` 
*   **Yeni Veri:** `{"fiyat": 1900, "miktar": 2, "odeme": "nakit"}`
*   **Değişen Alanlar:** `{"fiyat"}`

Mükemmel İz sürme!

## Edge Cases (Ölümcül Riskler) ve Diskin Şişmesi

Denetim masası çok havalıdır ama veritabanını çok kolay batırabilir:
1. **Disk İsrafı (Storage Bloat):** AstaFlow veritabanında toplu bir CSV İçe Aktarma (`Bulk Insert`) yaptık. 50.000 satır müşteri içeri aktarıldı. Ne oldu? Her satır için `INSERT TRIGGER` çalıştı ve AuditLog tablomuz anında 50.000 satır + Devşirme JSONB nesneleri ile dolup kapasite yedi!
   *Çözüm:* Batch (Toplu) transfer araçları kullanırken Trigger'ların geçici olarak askıya alınması veya sadece kritik CRUD için log kurallarının sınırlandırılmasıdır. Asla her logu sonsuza kadar tutmayın, pg_cron eklentisiyle "_6 aydan eski logları sil_" rutini yazın.
2. **Kişisel Veri İhlalleri (KVKK/GDPR):** Eğer kullanıcılar tablosunu Auditing içine alırsanız, kullanıcının "Eski Şifresi", "Kredi Kartı Tokeni" gibi gizli JSON alanlarını da açık-text olarak `old_data/new_data` bloğuna sokarsınız. Bu bir yasal güvenlik ihlalidir. 

## Bu Bilgiyi Nereden Biliyoruz? (Kaynaklar)

*   **AstaFlow Case Study:** [Değişim Geçmişi ve Yönetim Güvenliği Kurguları](/case-study/)
*   **İlgili Çözüm:** [Supabase Multi-Tenant Mimarileri](/blog/multi-tenant-uygulama-mimarisi-rehberi/)