Dev.to Security 🔐 Cybersecurity 👁 0 📖 12 min read

CRM'de rol tabanlı erişim tasarlamak: Kayıt sahipliği, ekip hiyerarşisi, satır düzeyi güvenlik ve alan maskeleme

"Danışman yalnızca kendi lead'ini görsün" cümlesi, CRM projelerinde ilk haftada duyulan en kısa gereksinimdir. Ben Koray Yalçın; lead yönetimi, CRM mimarisi, dijital pazarlama ve yapay zekâ destekli otomasyon projelerind

"Danışman yalnızca kendi lead'ini görsün" cümlesi, CRM projelerinde ilk haftada duyulan en kısa gereksinimdir. Ben Koray Yalçın; lead yönetimi, CRM mimarisi, dijital pazarlama ve yapay zekâ destekli otomasyon projelerinde, özellikle gayrimenkul ve PropTech tarafında çalışıyorum. Bu cümleyi her kurulumda duyuyorum ve her seferinde aynı şeyi görüyorum: Cümle kısa, ama arkasındaki yetki modeli kısa değil.

Bir konut projesinin satış ofisini düşünün. Danışmanlar kendi müşterilerini görmeli, ekip lideri ekibinin tamamını görmeli, satış müdürü tüm projeyi görmeli, çağrı merkezi aramayı yapabilmeli ama müşterinin ödeme planını görmemeli, pazarlama ekibi kampanya performansını görmeli ama tek tek telefon numaralarını dışa aktaramamalı. Üstüne izinli danışmanın lead'lerini geçici olarak devralan bir meslektaş, proje bazında çalışan dış acente ve raporları besleyen otomasyon kullanıcıları eklenir.

Bu yazıda rol tabanlı erişimi (role-based access control, RBAC) geliştirici gözüyle ele alıyorum: rolleri nasıl tanımlarsınız, "kimin kaydı" sorusunu nasıl modellersiniz, kuralı uygulama katmanında mı veritabanında mı uygularsınız, alan düzeyinde maskelemeyi nasıl yaparsınız ve tüm bunları nasıl test edersiniz.

Rol, izin ve kapsam: Üç ayrı kavram

İlk hata, rolü doğrudan yetkiyle eşitlemektir. Kodun her yerine if user.role == "team_lead" yazıldığında, ilk organizasyon değişikliğinde onlarca dosyaya dokunmanız gerekir. Ben üç kavramı baştan ayırıyorum:

  • İzin (permission): Yapılabilecek tek bir eylem. lead.read, lead.update, lead.export, payment_plan.read gibi.
  • Rol (role): İzinlerin adlandırılmış bir kümesi. "Danışman", "Ekip lideri", "Çağrı merkezi temsilcisi".
  • Kapsam (scope): İznin hangi kayıtlar için geçerli olduğu. "Yalnızca kendi kayıtları", "ekibinin kayıtları", "projenin kayıtları", "tümü".

Bu ayrım sayesinde "ekip lideri lead okuyabilir" ile "ekip lideri hangi lead'leri okuyabilir" sorusu ayrı yerlerde cevaplanır. Rolleri bir yapılandırma dosyasında tutmak, değişiklikleri incelenebilir ve sürümlenebilir kılar:

# roles.yaml
advisor:
  lead.read:            own
  lead.update:          own
  activity.create:      own
  payment_plan.read:    own
team_lead:
  lead.read:            team
  lead.update:          team
  lead.reassign:        team
  payment_plan.read:    team
call_center_agent:
  lead.read:            assigned_queue
  activity.create:      assigned_queue
  # payment_plan.read yok: ödeme planı görünmez
sales_manager:
  lead.read:            project
  lead.reassign:        project
  lead.export:          project
marketing_analyst:
  report.read:          all
  lead.read:            all_masked

Dikkat ederseniz marketing_analyst için all_masked diye ayrı bir kapsam var. Bazı roller kaydın varlığını ve toplu metriklerini görmeli, ama kişisel veriyi görmemelidir. Bunu "görür/görmez" ikiliğiyle değil, kapsamın bir parçası olarak modellemek işi kolaylaştırıyor.

"Kimin kaydı?" sorusunu veri modelinde cevaplamak

Kapsamlar ancak kayıt üzerinde sahiplik bilgisi doğru tutulursa çalışır. Lead tablosunda yalnızca owner_id tutmak ilk bakışta yeterli görünür; sahada ise en az şu bilgilere ihtiyaç duyuyorum:

  • owner_id: Kaydın sorumlu danışmanı.
  • team_id: Kaydın ait olduğu ekip. Danışmanın ekibinden türetilmez, kayıt üzerinde ayrıca saklanır. Danışman ekip değiştirdiğinde geçmiş kayıtların nereye gideceği bir iş kararıdır, otomatik bir yan etki olmamalıdır.
  • project_id: Lead'in ilgilendiği proje. Dış acenteler ve proje bazlı yöneticiler bu alanla sınırlandırılır.
  • Geçici yetki devirleri için ayrı bir tablo.
CREATE TABLE leads (
  id           uuid PRIMARY KEY,
  owner_id     uuid NOT NULL REFERENCES users(id),
  team_id      uuid NOT NULL REFERENCES teams(id),
  project_id   uuid NOT NULL REFERENCES projects(id),
  full_name    text NOT NULL,
  phone_e164   text,
  email        text,
  stage        text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

-- İzin, hastalık veya yoğunluk durumunda geçici devir
CREATE TABLE access_delegations (
  id            uuid PRIMARY KEY,
  from_user_id  uuid NOT NULL REFERENCES users(id),
  to_user_id    uuid NOT NULL REFERENCES users(id),
  valid_from    timestamptz NOT NULL,
  valid_until   timestamptz NOT NULL,
  reason        text NOT NULL,
  created_by    uuid NOT NULL REFERENCES users(id),
  CHECK (valid_until > valid_from)
);

Geçici devirleri sahipliği değiştirerek çözmek yaygın bir kısayoldur ve neredeyse her zaman sorun çıkarır. Danışman izinden döndüğünde kayıtların geri verilmesi unutulur, prim hesapları karışır, raporlarda lead'in "ilk sahibi" kaybolur. Devri ayrı bir tabloda, bitiş tarihiyle tutmak hem otomatik sona ermeyi hem de denetimi kolaylaştırır.

Hiyerarşi: Ekip ağacını sorguya çevirmek

"Ekip lideri ekibini görür" kuralı, organizasyon tek seviyeli olduğu sürece basittir. Bölge müdürü birden fazla ekibi, satış direktörü birden fazla bölgeyi yönettiğinde ekipler bir ağaç oluşturur. Bu ağacı her sorguda özyinelemeli olarak gezmek yerine, kullanıcı başına "erişebildiği ekipler" listesini önceden hesaplamayı tercih ediyorum:

CREATE TABLE user_team_access (
  user_id  uuid NOT NULL REFERENCES users(id),
  team_id  uuid NOT NULL REFERENCES teams(id),
  PRIMARY KEY (user_id, team_id)
);

-- Ekip ağacı değiştiğinde yeniden hesaplanır
WITH RECURSIVE subtree AS (
  SELECT t.id AS team_id, m.user_id
  FROM teams t
  JOIN team_managers m ON m.team_id = t.id
  UNION
  SELECT c.id, s.user_id
  FROM teams c
  JOIN subtree s ON c.parent_team_id = s.team_id
)
INSERT INTO user_team_access (user_id, team_id)
SELECT DISTINCT user_id, team_id FROM subtree
ON CONFLICT DO NOTHING;

Ön hesaplamanın bir bedeli var: Organizasyon değiştiğinde tabloyu yeniden üretmeyi unutursanız yetkiler eski kalır. Bu yüzden yeniden hesaplamayı ekip ve yönetici tablolarındaki değişikliğe bağlı bir işe bağlıyor, son hesaplama zamanını da izleme panelinde gösteriyorum.

Kuralı nerede uygulamalı: Uygulama mı, veritabanı mı?

En sık tartışılan soru bu. Uygulama katmanında, her sorguya bir WHERE owner_id = :current_user eklemek hızlı başlar; ama bir geliştiricinin yeni bir rapor uç noktasında bu filtreyi unutması yeterlidir. Benim tercihim katmanlı savunma: Uygulama katmanı yetkiyi kontrol eder ve anlamlı hata mesajı üretir, veritabanı ise son çizgi olarak satır düzeyi güvenliği (row-level security, RLS) uygular.

PostgreSQL'de bu yaklaşım şöyle görünür:

ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE leads FORCE ROW LEVEL SECURITY;

-- Uygulama her istekte oturum değişkenlerini ayarlar
-- SET LOCAL app.user_id = '...'; SET LOCAL app.scope = 'team';

CREATE POLICY lead_read ON leads
FOR SELECT
USING (
  CASE current_setting('app.scope', true)
    WHEN 'own' THEN
      owner_id = current_setting('app.user_id')::uuid
      OR owner_id IN (
        SELECT from_user_id FROM access_delegations
        WHERE to_user_id = current_setting('app.user_id')::uuid
          AND now() BETWEEN valid_from AND valid_until
      )
    WHEN 'team' THEN
      team_id IN (
        SELECT team_id FROM user_team_access
        WHERE user_id = current_setting('app.user_id')::uuid
      )
    WHEN 'project' THEN
      project_id IN (
        SELECT project_id FROM user_project_access
        WHERE user_id = current_setting('app.user_id')::uuid
      )
    WHEN 'all' THEN true
    ELSE false
  END
);

Burada iki ayrıntı önemli. Birincisi ELSE false: Oturum değişkeni ayarlanmamışsa sorgu boş döner, her şeyi döndürmez. Varsayılanın "kapalı" olması, yetki sistemlerinde pazarlık konusu olmamalı. İkincisi SET LOCAL: Bağlantı havuzu (connection pool) kullanıyorsanız, değişkeni işlem (transaction) kapsamında ayarlamazsanız bir kullanıcının kapsamı aynı bağlantıyı alan bir sonraki isteğe sızabilir. Bu hata testte görünmez, üretimde ise en kötü anda görünür.

Hazır bir CRM (HubSpot, Salesforce, Zoho, Bitrix24, Pipedrive gibi) kullanıyorsanız bu katmanı kendiniz yazmazsınız; ama aynı mantığı ürünün kendi araçlarıyla kurarsınız: sahiplik, ekip, paylaşım kuralları ve alan düzeyi görünürlük. Asıl risk, CRM'den veri çeken kendi servislerinizdedir. Entegrasyon kullanıcısı genellikle "her şeyi gören" bir hesaptır ve onun üzerinden beslenen bir rapor ekranı, CRM'de özenle kurduğunuz bütün kuralları sessizce atlar.

Alan düzeyi erişim ve maskeleme

Satır düzeyi güvenlik "hangi kayıtları" sorusunu çözer; "kaydın hangi alanlarını" sorusu ayrı bir katmandır. Çağrı merkezi temsilcisi telefon numarasını kullanmalı ama ödeme planını görmemeli; pazarlama analisti lead'in kaynağını ve aşamasını görmeli ama telefonu görmemeli.

Ben alan görünürlüğünü üç seviyede tanımlıyorum: tam, maskeli ve gizli. Maskeleme sunum katmanında değil, verinin servisten çıktığı noktada yapılmalı. Arayüzde yıldızlanan ama API yanıtında açık giden bir telefon numarası, maskelenmiş sayılmaz.

FIELD_POLICY = {
    "phone_e164": {"advisor": "full", "call_center_agent": "full",
                   "marketing_analyst": "masked"},
    "email":      {"advisor": "full", "marketing_analyst": "masked"},
    "payment_plan": {"advisor": "full", "team_lead": "full"},
}

def mask_phone(value: str) -> str:
    return value[:4] + "*" * (len(value) - 6) + value[-2:] if value else value

MASKERS = {"phone_e164": mask_phone,
           "email": lambda v: v.split("@")[0][:1] + "***@" + v.split("@")[1] if v else v}

def serialize_lead(lead: dict, role: str) -> dict:
    out = {}
    for field, value in lead.items():
        level = FIELD_POLICY.get(field, {}).get(role, "full" if field not in FIELD_POLICY else "hidden")
        if level == "full":
            out[field] = value
        elif level == "masked":
            out[field] = MASKERS.get(field, lambda v: None)(value)
        # hidden: alan yanıta hiç eklenmez
    return out

Bu fonksiyonda dikkat edilmesi gereken kural şu: Politika tablosunda tanımlı bir alan için rol listede yoksa sonuç "gizli"dir. Yeni bir rol eklendiğinde hassas alanlar otomatik olarak açılmaz, bilinçli olarak açılır. KVKK açısından da veri minimizasyonu ilkesine uygun olan yaklaşım budur: Her rol, işini yapmak için gereken en az veriyi görür.

Dışa aktarma, toplu işlem ve otomasyon kullanıcıları

Yetki modellerinin en çok delindiği yer okuma ekranı değil, toplu işlemlerdir. Tek tek kayıt görüntülemek ile on bin kaydı CSV olarak indirmek aynı izin olmamalı. Bu yüzden lead.export izni ayrı tutulur, dışa aktarma işlemleri ayrıca kayıt altına alınır ve belirli bir satır sayısının üzerindeki indirmeler için yönetici onayı istenebilir.

Otomasyon ve entegrasyon kullanıcıları için de aynı disiplin geçerli. Her entegrasyona ayrı bir servis hesabı, yalnızca ihtiyaç duyduğu izinler ve mümkünse proje ya da nesne bazında kapsam veriyorum. "Zapier kullanıcısı", "rapor servisi" ve "WhatsApp entegrasyonu" aynı yönetici hesabını paylaştığında, bir API anahtarının sızması tüm müşteri verisinin sızması anlamına gelir. Anahtarları düzenli olarak yenilemek ve hangi anahtarın en son ne zaman kullanıldığını izlemek, bu riski görünür kılar.

Yetkiyi test etmek: Matris testleri

Yetki kodu, en az test edilen ama hatası en pahalı olan koddur. Ben her rol ve kapsam için "görmeli" ve "görmemeli" vakalarını içeren bir matris testi yazıyorum. Önemli olan yalnızca erişimin verildiğini değil, verilmediğini de doğrulamak:

import pytest

CASES = [
    # (rol, kullanıcı, lead, beklenen)
    ("advisor",   "ayse",   "lead_ayse_1",  True),
    ("advisor",   "ayse",   "lead_mehmet_1", False),
    ("advisor",   "ayse",   "lead_can_1",   True),   # aktif devir var
    ("team_lead", "zeynep", "lead_mehmet_1", True),
    ("team_lead", "zeynep", "lead_other_team_1", False),
    ("call_center_agent", "ali", "lead_mehmet_1", False),  # kuyruğunda değil
]

@pytest.mark.parametrize("role,user,lead,expected", CASES)
def test_lead_visibility(db, role, user, lead, expected):
    with db.as_user(user, role) as session:
        visible = session.fetch_lead(lead) is not None
    assert visible == expected

Bu testlerin veritabanı politikalarına karşı, gerçek bir PostgreSQL örneğinde çalışması önemli. Sahte (mock) veritabanıyla yazılan yetki testleri, tam olarak test etmek istediğiniz katmanı atlar. Devir süresinin dolması, ekip değişikliği ve rolü değiştirilen kullanıcı gibi zamana bağlı senaryoları da ayrı vakalar olarak ekliyorum.

Denetim: Kim neyi gördü?

Yetki modelinin son parçası görünürlüktür. Kayıt değişikliklerini tutan denetim kaydının yanında, hassas alanlara erişimi ve dışa aktarmaları da kayıt altına almak gerekir. "Bu müşterinin telefon numarasını geçen ay kimler görüntüledi?" sorusuna cevap verebilmek, hem KVKK kapsamındaki başvurularda hem de bir veri sızıntısı şüphesinde işinizi ciddi ölçüde kolaylaştırır. Ancak her okuma işlemini kaydetmek hacmi hızla büyütür; ben yalnızca hassas alanların açık hâliyle görüntülenmesini, dışa aktarmaları ve yetki değişikliklerini kaydediyorum.

Kavramın kısa tanımını ve gayrimenkul satış operasyonundaki yerini kendi sitemdeki sözlükte özetledim: Role-based access nedir? Bu yazı ise o tanımın geliştirici tarafındaki uygulama notları.

SSS

RBAC yerine doğrudan öznitelik tabanlı erişim (ABAC) kullanmak daha mı iyi?

Öznitelik tabanlı erişim (attribute-based access control) daha esnektir ama okunması ve denetlenmesi zordur. Pratikte iyi çalışan yol, roller üzerinden izinleri tanımlayıp kapsamı kayıt öznitelikleriyle (sahip, ekip, proje) belirlemektir. Yukarıdaki model de aslında bu iki yaklaşımın bir karışımıdır.

Satır düzeyi güvenlik performansı düşürür mü?

Politikalardaki alt sorgular indekslenmiş sütunlara dayandığında etki genellikle küçüktür. owner_id, team_id ve project_id üzerinde indeks, erişim tablolarında da birleşik birincil anahtar olması gerekir. Sorgu planını politikalar açıkken incelemek, sürprizleri erken yakalar.

Yönetici kullanıcılar her şeyi görmeli mi?

Görebilmeli, ama bu erişim de kayıt altına alınmalı. "Süper kullanıcı" hesaplarının sayısını az tutmak, günlük işler için ayrı ve sınırlı bir hesap kullanmak iyi bir alışkanlıktır.

Danışman şirketten ayrıldığında kayıtları ne olmalı?

Hesap hemen devre dışı bırakılmalı, kayıtlar ise bir yöneticinin kararıyla yeni sahiplere aktarılmalıdır. Aktarım toplu bir işlem olarak kaydedilmeli; kaydın ilk sahibi ise raporlama için ayrı bir alanda korunmalıdır.

Hazır CRM kullanıyorsam bu tasarımın ne kadarı bana kalır?

Kayıt ve alan düzeyi kuralları büyük ölçüde CRM'in kendi ayarlarıyla kurulur. Size kalan kısım entegrasyon hesaplarının kapsamı, CRM'den veri çeken kendi servisleriniz, dışa aktarma politikası ve bütün bunların düzenli test edilmesidir.

Rol tabanlı erişim, bir ayar ekranındaki onay kutuları değil, organizasyonun nasıl çalıştığının veri modeline yansımasıdır. Rol, izin ve kapsamı ayırdığınızda, varsayılanı kapalı tuttuğunuzda ve "görmemeli" vakalarını da test ettiğinizde, ekip büyüdükçe yetki modeli de sizinle birlikte büyür.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.