İçeriğe geç

Communications & Credentials Audit Panel — Design

Derin

Status: Approved (brainstorming complete) Date: 2026-05-03 Owner: Admin Panel Related security debt: memory/security_findings.md (TrialCredential plaintext)

Admin panele iki yetenek eklemek:

  1. Communications log — müşteriye gönderilen tüm e-posta ve SMS iletilerinin tam içeriğini (rendered HTML, plain text, headers, template payload) kalıcı olarak loglamak ve admin UI’da forensic-level görüntüleme, filtre, yeniden gönderme, manuel silme ve CSV export yetenekleri sunmak.

  2. Credential visibility — provisionlanan tüm portallar için admin login, customer login, SMTP/IMAP ve API token credential’larını hem portal-bazında (subdomain × credential matrix) hem tip-bazında (cross-portal admin/customer/mail/API listeleri) görünür kılmak. Reveal/copy aksiyonları audit log’a yazılır.

İki yetenek aynı admin layout altında, paylaşılan audit ve crypto altyapısını kullanarak tek spec olarak modellenir.

  • TrialCredential plaintext alanlarını AES-256-GCM ile şifrelemek (ayrı security spec’inde ele alınacak — security debt listesinde kayıtlı).
  • Müşteri tarafından alınan (inbound) e-posta veya SMS’leri loglamak — sadece giden iletişim.
  • Mevcut EmailQueue retry mekanizmasını yeniden yazmak — log entegrasyonu sadece referans (emailLogId) ekler.
  • 2FA reauth gibi reveal-time friction — perimetre auth (admin login) yeterli kabul edilir, audit log post-mortem güvenliği sağlar.
  • AdminUser role/permission sistemi — şu an tüm AdminUser eşit yetkiye sahip, scope dışı.
┌─────────────────────────────────────────────────────────────────┐
│ Existing call sites (~30 mail + ~5 SMS) — değişmez API │
│ sendMail({...}) / sendSms({...}) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ lib/mail/service.ts / lib/sms/service.ts │
│ 1. Render template │
│ 2. INSERT EmailLog/SmsLog (status: pending) │
│ 3. Encrypt payload data → MailPayload/SmsPayload │
│ 4. Transport.send() │
│ 5. UPDATE EmailLog/SmsLog (status, messageId, error) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ • EmailLog (full content, TOAST-compressed) │
│ • SmsLog │
│ • MailPayload / SmsPayload (AES-256-GCM ciphertext) │
│ • AdminAuditLog (mevcut — yeni action enum değerleri) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Admin UI (mevcut admin layout altında) │
│ /admin/communications │
│ ├── /emails liste + filtre + detay drawer │
│ ├── /sms liste + filtre + detay drawer │
│ /admin/credentials │
│ ├── /by-portal subdomain listesi │
│ │ └── /[subdomain] 4 credential bloğu (admin/cust/mail/api) │
│ └── /by-type /admins, /customers, /mail, /api │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Cron — /api/cron/log-retention (günde 1) │
│ 90 günden eski EmailLog/SmsLog + payload satırlarını siler │
└─────────────────────────────────────────────────────────────────┘
model EmailLog {
id Int @id @default(autoincrement())
template String // 'magic-link', 'trial-ready' vb.
recipient String // virgülle ayrılmış (multi-recipient)
subject String
html String @db.Text // PostgreSQL TOAST + LZ4
text String @db.Text
headers Json // List-Unsubscribe, Message-ID dahil
locale String?
status String @default("pending") // pending | sent | failed | retrying
messageId String? // SMTP message-id
errorMessage String?
attemptCount Int @default(1)
customerId Int?
adminId Int?
ipAddress String?
createdAt DateTime @default(now())
sentAt DateTime?
payload MailPayload?
@@index([template])
@@index([recipient])
@@index([customerId])
@@index([status])
@@index([createdAt])
}
model MailPayload {
emailLogId Int @id
ciphertext Bytes // AES-256-GCM(JSON.stringify(data))
iv Bytes
authTag Bytes
emailLog EmailLog @relation(fields: [emailLogId], references: [id], onDelete: Cascade)
}
model SmsLog {
id Int @id @default(autoincrement())
template String // 'otp', 'login-alert' vb.
recipient String // E.164
body String @db.Text
fromNumber String?
locale String?
status String @default("pending") // pending | sent | failed
twilioSid String?
errorMessage String?
customerId Int?
adminId Int?
ipAddress String?
createdAt DateTime @default(now())
sentAt DateTime?
payload SmsPayload?
@@index([template])
@@index([recipient])
@@index([customerId])
@@index([status])
@@index([createdAt])
}
model SmsPayload {
smsLogId Int @id
ciphertext Bytes
iv Bytes
authTag Bytes
smsLog SmsLog @relation(fields: [smsLogId], references: [id], onDelete: Cascade)
}

lib/crypto/payload-cipher.ts — AES-256-GCM, MAIL_PAYLOAD_KEY env (32-byte hex). PasswordEntry cipher pattern’i ile aynı API ama ayrı key (PII vs. credential ayrımı).

export function encryptPayload(plaintext: string): { ciphertext: Buffer; iv: Buffer; authTag: Buffer };
export function decryptPayload(c: { ciphertext: Buffer; iv: Buffer; authTag: Buffer }): string;
  • TrialCredential — okunur, yazılmaz. Plaintext alanlar olduğu gibi kalır.
  • PortalInstallationTrialCredential ile JOIN edilir (subdomain üzerinden).
  • AdminAuditLog — yeni action string değerleri (migration gerekmez, string column).
app/[locale]/admin/(panel)/
├── communications/
│ ├── layout.tsx shared header + tabs
│ ├── page.tsx redirect → /emails
│ ├── emails/
│ │ ├── page.tsx liste (server component)
│ │ ├── components/
│ │ │ ├── email-log-table.tsx
│ │ │ ├── email-log-filters.tsx
│ │ │ └── email-detail-drawer.tsx
│ │ └── [id]/
│ │ ├── page.tsx deep-link standalone detay
│ │ └── preview/route.tsx sandboxed iframe srcdoc
│ └── sms/
│ ├── page.tsx
│ ├── components/{sms-log-table, sms-detail-drawer}.tsx
│ └── [id]/page.tsx
└── credentials/
├── layout.tsx header + sekme bar
├── page.tsx default: by-portal sekmesi
├── by-portal/
│ ├── page.tsx portal liste tablosu
│ └── [subdomain]/page.tsx portal detay (4 credential bloğu)
└── by-type/
├── admins/page.tsx
├── customers/page.tsx
├── mail/page.tsx
└── api/page.tsx
app/api/admin/
├── communications/
│ ├── emails/
│ │ ├── route.ts GET list (cursor pagination)
│ │ ├── [id]/
│ │ │ ├── route.ts GET detail, DELETE single
│ │ │ ├── payload/route.ts GET decrypted payload (audit'e yazar)
│ │ │ ├── preview/route.ts GET sandboxed HTML
│ │ │ └── resend/route.ts POST tekrar gönder (sebep zorunlu)
│ │ └── export/route.ts GET CSV/JSON
│ └── sms/{aynı yapı}
└── credentials/
├── portals/route.ts GET list
├── portals/[subdomain]/route.ts GET tek portal'ın tüm credential'ları
├── by-type/{admins,customers,mail,api}/route.ts
└── reveal/route.ts POST { credentialId, field, source }
  • Liste sayfaları: server-side filtering, cursor pagination (25 satır/sayfa). Filtre alanları: template, status, locale, date range, recipient (LIKE), full-text body search (opsiyonel, Phase 5).
  • Detail drawer: sağdan slide, 4 tab (Rendered / Plain Text / Headers / Payload). Rendered tab <iframe srcdoc={html} sandbox=""> — hiçbir flag yok (no allow-scripts, no allow-same-origin). Bu kombinasyon iframe’i opaque origin’e koyar; script çalışmaz, parent DOM/cookie/storage erişimi yok, form post engellenir. allow-same-origin parent’a erişim verdiği için kullanılmaz.
  • Reveal/Copy: tek endpoint, source: 'view' | 'copy' parametresi ile ayrım yapılır. POST /api/admin/credentials/reveal { credentialId, field, source } veya POST /api/admin/communications/{kind}/{id}/payload { source }. Server source değerine göre credential.reveal veya credential.copy audit kaydı yazıp plaintext döner. Frontend view modunda 30 saniye sonra otomatik mask + countdown bar gösterir; copy modunda plaintext clipboard’a yazılır ve UI’da gösterilmez.
  • Resend: zorunlu sebep alanı, yeni EmailLog satırı oluşturur (eskisini değiştirmez), audit communications.email.resend + target: emailLog:OLD → emailLog:NEW.
  • Delete: hem detail drawer hem context menu, audit communications.email.delete.
  • Export: GET stream (CSV veya JSON), filtre parametreleri reuse, audit communications.email.export ile satır sayısı.

lib/mail/service.ts sendMail fonksiyonu opsiyonel context parametresi alır:

export async function sendMail<T extends keyof TemplatePropsMap>(options: {
to: string | string[];
template: T;
data: TemplatePropsMap[T];
context?: { customerId?: number; adminId?: number; ipAddress?: string };
}): Promise<SendMailResult>

Akış:

  1. renderTemplate() — başarısız olursa log YAZILMAZ (içerik yok), success: false döner.
  2. prisma.emailLog.create({ status: 'pending', ... }) — başarısız olursa transport çağrısı yapılmaz, sessiz kayıp önlenir.
  3. encryptAndStorePayload('email', log.id, options.data) — başarısız olursa log’a errorMessage: "payload_encrypt_failed: ..." yazılır, transport yine de devam eder.
  4. transporter.sendMail({ ... }) — başarılı: status: sent, messageId, sentAt update.
  5. Transport hatası: status: retrying, errorMessage set, enqueueEmail({ ..., emailLogId: log.id }). Queue retry başarılı olunca log sent’e döner; üç deneme sonrası başarısız olursa failed’e düşer.

lib/sms/service.ts aynı pattern, queue/retry yok. Adımlar 1-4 aynı, hata durumunda status: failed direkt set edilir.

lib/mail/queue.ts retry job’u emailLogId taşır. Job tamamlanınca log status ve attemptCount güncellenir. Mevcut queue retry logic’i değişmez, sadece log referansı eklenir.

context parametresi opsiyonel — mevcut çağrılar değişmeden çalışır, sadece customerId/adminId log’da null kalır. Phase 7’de batch enrichment yapılır:

Mail call sites (~30):

  • app/api/admin/auth/{magic-link,forgot-password,login}/route.ts → adminId, ipAddress
  • app/api/admin/{users,trial,orders}/... → adminId, ipAddress
  • app/api/payment/{confirm-sepa,confirm-crypto,webhook}/route.ts → customerId, ipAddress
  • app/api/customer/{2fa,account,auth}/... → customerId, ipAddress
  • app/api/cron/trial-expiry/route.ts → source: ‘cron’
  • app/api/contact/route.ts → ipAddress (anonim)
  • app/lib/{customer,admin-customer}-actions.ts → caller’dan
  • lib/ticket-notifications.ts → customerId/adminId duruma göre

SMS call sites (~5):

  • app/api/{admin,customer}/auth/sms-otp/{send,verify}/route.ts → ilgili id, ipAddress
  • app/api/contact/route.ts → ipAddress

Mevcut AdminAuditLog tablosu kullanılır (string action kolonu, migration gerekmez).

communications.email.list_view (dedupe: 1/session/day)
communications.email.detail_view
communications.email.body_view
communications.email.payload_reveal
communications.email.resend
communications.email.delete
communications.email.export
communications.sms.{list_view, detail_view, body_view, payload_reveal, resend, delete, export}
credential.portal_list_view (dedupe: 1/session/day)
credential.portal_detail_view
credential.type_list_view (dedupe: 1/session/day)
credential.reveal
credential.copy
// lib/audit/log.ts (mevcut yoksa eklenir)
await logAdminAudit({
adminId, action, target, details, ipAddress, userAgent,
status: 'success' | 'failed' | 'blocked',
});

list_view aksiyonları için in-memory deduplication: ilk hit kaydedilir, aynı admin/aynı sayfa için 24 saat içinde tekrar yazılmaz.

/admin/security (varsa) audit log filter chip’lerine Communications ve Credentials kategorileri eklenir.

app/api/cron/log-retention/route.ts — günde 1, CRON_SECRET ile korunur.

const cutoff = new Date(Date.now() - LOG_RETENTION_DAYS * 86400000);
await prisma.emailLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
await prisma.smsLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
// payload onDelete: Cascade ile otomatik silinir
  • Manuel silme: admin panelden tek satır silme (audit logged).
  • Müşteri silindiğinde: EmailLog.customerId ve SmsLog.customerId SetNull ile null’a düşer — log kalır, PII bağı kopar.
  • İleride aktive edilebilir: payload kısa retention (30 gün) + rendered log uzun retention (90 gün) ayrımı; schema bunu destekler.
LOG_RETENTION_DAYS=90
MAIL_PAYLOAD_KEY=<32-byte hex>
CRON_SECRET=<existing>
SenaryoDavranış
EmailLog.create başarısız (DB down)console.error, transport çağrılmaz, success: false döner. Mail gönderilmemiş kabul edilir.
renderTemplate başarısızLog YAZILMAZ, success: false (içerik yok).
Transport başarısızLog status: retrying, errorMessage set, queue’ya alınır.
Payload encrypt başarısızLog yazılır, payload null, errorMessage: "payload_encrypt_failed: ...". Transport devam eder.
Reveal endpoint anonim/yetkisiz401, audit status: blocked.
Decrypt başarısız500, audit status: failed, kullanıcıya genel hata.
Resend orijinal data corrupt (decrypt başarısız)Resend reddedilir, audit status: failed.
Resend başarılıYeni EmailLog satırı, eskisi değişmez, audit chain OLD → NEW.
  • EmailLog.create ek latency: ~5-15 ms (PostgreSQL local). Mevcut transport ~200-500 ms; %3-7 ek yük.
  • HTML body en kötü ~100KB. 1.000 mail/gün × 100KB = 100MB/gün ham; TOAST + LZ4 ile ~10-20MB/gün gerçek. 90 gün retention = ~1-2GB.
  • SMS body ≤ 1.530 char (multi-segment); günde 100 SMS × 200B = 20KB/gün.
  • Indexler: B-tree composite (template, createdAt), (recipient, createdAt), (customerId, createdAt), (status, createdAt). Full-text body search için GIN index opsiyonel — Phase 5’te ihtiyaca göre.
KatmanSenaryoAraç
Unitpayload-cipher.ts encrypt/decrypt round-trip, yanlış key reddiVitest
UnitAudit list_view dedup logicVitest
IntegrationsendMail → EmailLog satırı, transport mock, status sentVitest + test DB
IntegrationTransport hatası → retrying → queue → sent veya failedVitest
IntegrationReveal endpoint → credential.reveal audit kaydıVitest
IntegrationRetention cron → 90 gün öncesi siler, payload cascadeVitest
Securityiframe sandbox (sandbox=""): <script>alert()</script> payload’ı admin panelde çalışmıyor, parent.document erişimi engelliManuel
SecurityReveal endpoint anonim → 401curl
Smoke50+ template’in her biri için sendMail → log oluşurSmoke harness’a ek
  1. Prisma migration 1EmailLog, SmsLog, MailPayload, SmsPayload create.
  2. EnvMAIL_PAYLOAD_KEY üretip .env’e ekle (deploy öncesi).
  3. Code release 1lib/mail/service.ts, lib/sms/service.ts log yazımı, payload-cipher.ts. Tüm yeni iletişim log’a düşer.
  4. Code release 2 — Admin UI sayfaları (/communications/{emails,sms}, /credentials/{by-portal,by-type}).
  5. Code release 3 — Audit log entegrasyonu, reveal endpoint, copy/delete/resend aksiyonları.
  6. Code release 4 — Retention cron, CSV/JSON export, full-text body search (opsiyonel).
  7. Phase opsiyonel — call site enrichment (customerId/adminId ekleme).

Backfill yok — bugünden ileri log tutulur.

  • Payload short-retention (30 gün) ayrı cron — şu an scope dışı, schema destekliyor.
  • Key rotationkey_version kolonu eklenebilir; YAGNI, tek key.
  • Full-text body search — Phase 5’te ihtiyaca göre GIN index.
  • TrialCredential plaintext encryption — ayrı security spec, security debt listesinde.
  • AdminUser role/permission — şu an scope dışı, gelecekte super-admin ayrımı gerekirse.