V1'in birinci günden multi-tenant olmasının nedeni: self-host'ta tek tenant olarak çalıştığında bile tüm tablo, query, cache ve session katmanları tenantId-aware olmalı. SaaS'a geçişte refactor sıfır. Bu doküman tenant modelini, izolasyon mekanizmalarını ve cross-tenant saldırı vektörlerine karşı savunmaları detaylandırır.
Karşıt görüş: "Self-host tek kullanıcı, multi-tenant ekstra karmaşıklık."
Cevap: Karmaşıklık tutarlı. Sonradan eklemek 10x maliyetli:
- Her query'ye
WHERE tenantId=?eklemek = 200+ dosya değişikliği - Her cache key'e
<tenantId>:prefix eklemek = bug magneti - Session/JWT'ye tenant scope eklemek = re-auth requirement
- Audit log'a tenant boyutu eklemek = backfill imkansız
- RBAC scope'unu org → tenant'a genişletmek = data leak riski
V1'de:
- Default org otomatik oluşturulur (
tenant-default) - Self-host kullanıcı bunu görmez, "Settings → Organization" gizli
- Database, cache, queue tamamen tenant-scoped
V2 SaaS açılınca:
- Signup → yeni
Tenantrow → 0 ek refactor - Cross-tenant izolasyon zaten test edilmiş (Phase 1'den beri)
3 yaygın model:
| Model | Açıklama | Avantaj | Dezavantaj |
|---|---|---|---|
| A — Shared DB + Shared Schema | Tüm tenant'lar aynı tablolarda, tenantId column |
Düşük cost, kolay backup, kolay migration | RLS şart; query mistake = data leak |
| B — Shared DB + Schema-per-Tenant | Postgres schemas; SET search_path=tenant_x |
Daha yüksek izolasyon, per-tenant migration kolay | Migration N tablo × M tenant = patlama |
| C — Database-per-Tenant | Her tenant ayrı DB | Maksimum izolasyon, custom encryption per-tenant | Ops karmaşık, connection pool patlar |
Niye:
- 10K tenant'a kadar tek Postgres yeter (bizim ölçek için sınırsız)
- Postgres Row-Level Security (RLS) policies ile runtime kontrolü; query'de unuttuysan bile DB reddeder
- Backup tek
pg_dump - Migration tek
prisma migrate deploy - Per-tenant ihtiyaç (örn. enterprise customer dedicated DB) için migration path açık — V2.5+'ta hybrid model
Risk azaltma:
- RLS fail-closed (default policy: deny)
- Connection-level session var:
SET app.current_tenant = '<uuid>' - Test suite cross-tenant leak için zorunlu (bkz.
14_QA_TESTING.md)
Tenant (en üst — bir müşteri/org/şirket)
├── Members (Users + Roles)
│ ├── OWNER — billing, delete tenant
│ ├── ADMIN — tenant settings, invitations
│ ├── MAINTAINER — credentials, apps add/remove
│ ├── EDITOR — metadata edit + push
│ └── VIEWER — read-only
├── Apps (iOS / Android)
│ ├── Localizations
│ ├── Screenshots
│ ├── App Previews
│ └── Builds (V1.5)
├── Audit Log
├── Settings (preferences, defaults)
├── Invitations (pending)
└── Subscription (V2 SaaS)
├── Plan (Free / Pro / Team / Enterprise)
├── Usage (apps, locales, pushes/month)
└── Billing (Stripe customer ID)
Bir kullanıcı (User) birden fazla Tenant'ın üyesi olabilir (V2 SaaS):
User ←→ TenantMembership ←→ Tenant(many-to-many)- Default tenant: kullanıcının ilk login'inde aktif olan (cookie'de saklanır)
- Tenant switcher: topbar'da, Cmd+K palette'de
Tüm "tenant'a ait" tablolar şu kurala uyar:
tenantIdcolumn zorunlu (NOT NULL, indexed)- Composite unique constraints
tenantIdiçerir (örn. App:@@unique([tenantId, platform, bundleId])) - Foreign key'ler tenantId ile birlikte:
App.credentialId → Credential.iddeğil, (tenantId, credentialId) — tenant boundary'sini cross etmek imkansız hale gelir - RLS policy her tabloda
model Tenant {
id String @id @default(uuid()) @db.Uuid
slug String @unique // "acme-corp" — URL-safe
name String
// Tenant lifecycle
status TenantStatus @default(ACTIVE)
deployedAs DeployMode @default(SELF_HOST) // SELF_HOST | SAAS
// SaaS-only fields (null in self-host)
stripeCustomerId String?
planTier PlanTier @default(FREE)
trialEndsAt DateTime?
// Limits (per-plan; can be overridden)
maxApps Int @default(5)
maxMembers Int @default(3)
maxPushesPerMonth Int @default(100)
// Metadata
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
members TenantMember[]
invitations Invitation[]
credentials Credential[]
apps App[]
auditEvents AuditEvent[]
jobs Job[]
settings TenantSetting[]
subscription Subscription?
usageRecords UsageRecord[]
@@index([slug])
@@index([status])
}
enum TenantStatus {
ACTIVE
SUSPENDED // billing failure, abuse
PENDING_DELETE // 30-day grace period
}
enum DeployMode {
SELF_HOST
SAAS
}
enum PlanTier {
FREE
PRO
TEAM
ENTERPRISE
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
emailVerifiedAt DateTime?
passwordHash String? // null = SSO-only
displayName String
avatarUrl String?
// Account state
status UserStatus @default(ACTIVE)
// MFA (V1.5+)
totpSecret String? // encrypted
webauthnEnabled Boolean @default(false)
// Onboarding
defaultTenantId String? @db.Uuid // cookie fallback
// Audit
lastLoginAt DateTime?
lastLoginIp String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
memberships TenantMember[]
invitationsSent Invitation[] @relation("inviter")
auditEventsCreated AuditEvent[]
jobsTriggered Job[]
apiTokens ApiToken[]
}
enum UserStatus {
ACTIVE
DISABLED
PENDING_VERIFICATION
}
model TenantMember {
tenantId String @db.Uuid
userId String @db.Uuid
role TenantRole
// Per-app permissions (V2)
allowedAppIds String[] @default([]) // boş = tüm app'ler
// Metadata
joinedAt DateTime @default(now())
invitedById String? @db.Uuid
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tenantId, userId])
@@index([userId]) // user → tenants lookup
}
enum TenantRole {
OWNER
ADMIN
MAINTAINER
EDITOR
VIEWER
}
model Invitation {
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid
email String
role TenantRole
tokenHash String @unique // raw token email'de, hash DB'de
invitedById String @db.Uuid
expiresAt DateTime // 7 gün
acceptedAt DateTime?
acceptedById String? @db.Uuid
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
inviter User @relation("inviter", fields: [invitedById], references: [id])
@@unique([tenantId, email], name: "uniq_pending_per_email")
@@index([tokenHash])
@@index([expiresAt])
}
model Session {
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid
// Active tenant context — cookie'de sessionId, DB'de hangi tenant aktif
activeTenantId String? @db.Uuid
tokenHash String @unique
userAgent String?
ipAddress String?
expiresAt DateTime
createdAt DateTime @default(now())
lastUsedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([tokenHash])
@@index([userId])
@@index([expiresAt])
}
model ApiToken {
// V2 — programmatic API access (PAT)
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid
tenantId String @db.Uuid // tenant-scoped!
name String // "CI bot"
tokenHash String @unique
prefix String // "gp_abcdef..." gösterirken
scopes String[] // ["apps:read", "metadata:push"]
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([tenantId])
@@index([tokenHash])
}
// ──────────────────────────────────────────────────────────────────────────
// TENANT-SCOPED MODELS (every model has tenantId)
// ──────────────────────────────────────────────────────────────────────────
model Credential {
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid // ← TENANT SCOPE
kind CredentialKind
name String
appleKeyId String?
appleIssuerId String?
googleClientEmail String?
googleProjectId String?
secretRef String // SecretProvider URI
lastTestedAt DateTime?
lastTestSucceeded Boolean?
lastTestMessage String?
createdById String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rotatedAt DateTime?
isActive Boolean @default(true)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
apps App[]
@@index([tenantId, kind])
@@index([tenantId, isActive])
}
enum CredentialKind {
APPLE
GOOGLE
}
model App {
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid // ← TENANT SCOPE
credentialId String @db.Uuid
platform Platform
bundleId String
storeAppId String?
appName String
primaryLocale String
// ... (önceki şemada olduğu gibi devam)
versionId String?
versionString String?
status String?
releaseType ReleaseType?
earliestReleaseDate DateTime?
copyright String?
teamId String?
availableLanguages String[] @default([])
discoveredScreenshotTypes String[] @default([])
discoveredPreviewTypes String[] @default([])
storeUrl String?
isConnected Boolean @default(true)
dirty Boolean @default(false)
lastFetchedAt DateTime?
lastPushedAt DateTime?
createdById String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
credential Credential @relation(fields: [credentialId], references: [id])
localizations AppLocalization[]
screenshots Screenshot[]
appPreviews AppPreview[]
androidImages AndroidImage[]
jobs Job[]
auditEvents AuditEvent[]
// ÖNEMLİ: tenant boundary'sini cross etmek imkansız — composite key
@@unique([tenantId, platform, bundleId])
@@index([tenantId])
@@index([tenantId, credentialId])
}
// ... AppLocalization, Screenshot, AppPreview, AndroidImage, Job, AuditEvent
// hepsi `tenantId String @db.Uuid` field'ı + @@index([tenantId, ...]) içerir.
// Composite unique'ler tenantId ile başlar.
// Subscription + Usage (V2 SaaS)
model Subscription {
id String @id @default(uuid()) @db.Uuid
tenantId String @unique @db.Uuid
stripeSubId String? @unique
status SubscriptionStatus
planTier PlanTier
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}
enum SubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
UNPAID
}
model UsageRecord {
// Per-month aggregate for billing
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid
yearMonth String // "2026-05"
metric String // "metadata.push", "screenshot.upload"
count Int @default(0)
recordedAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, yearMonth, metric])
@@index([tenantId, yearMonth])
}Postgres'te RLS, migration ile aktive edilir — Prisma raw SQL ile:
-- packages/db/prisma/migrations/0002_enable_rls/migration.sql
-- Enable RLS on every tenant-scoped table
ALTER TABLE "Credential" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "App" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AppLocalization" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Screenshot" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AppPreview" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AndroidImage" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Job" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AuditEvent" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TenantSetting" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Invitation" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "UsageRecord" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Subscription" ENABLE ROW LEVEL SECURITY;
-- Force RLS even for table owner (Prisma connection)
ALTER TABLE "Credential" FORCE ROW LEVEL SECURITY;
-- ... (her tabloda)
-- Standard policy: rows belong to current tenant
CREATE POLICY tenant_isolation_select ON "Credential"
FOR SELECT USING ("tenantId" = current_setting('app.current_tenant', true)::uuid);
CREATE POLICY tenant_isolation_insert ON "Credential"
FOR INSERT WITH CHECK ("tenantId" = current_setting('app.current_tenant', true)::uuid);
CREATE POLICY tenant_isolation_update ON "Credential"
FOR UPDATE USING ("tenantId" = current_setting('app.current_tenant', true)::uuid)
WITH CHECK ("tenantId" = current_setting('app.current_tenant', true)::uuid);
CREATE POLICY tenant_isolation_delete ON "Credential"
FOR DELETE USING ("tenantId" = current_setting('app.current_tenant', true)::uuid);
-- Aynı pattern her tablo için. Helper SQL function ile DRY:
CREATE OR REPLACE FUNCTION apply_tenant_isolation(table_name text) RETURNS void AS $$
BEGIN
EXECUTE format('
CREATE POLICY tenant_iso_sel ON %I FOR SELECT USING ("tenantId" = current_setting(''app.current_tenant'', true)::uuid);
CREATE POLICY tenant_iso_ins ON %I FOR INSERT WITH CHECK ("tenantId" = current_setting(''app.current_tenant'', true)::uuid);
CREATE POLICY tenant_iso_upd ON %I FOR UPDATE USING ("tenantId" = current_setting(''app.current_tenant'', true)::uuid) WITH CHECK ("tenantId" = current_setting(''app.current_tenant'', true)::uuid);
CREATE POLICY tenant_iso_del ON %I FOR DELETE USING ("tenantId" = current_setting(''app.current_tenant'', true)::uuid);
', table_name, table_name, table_name, table_name);
END;
$$ LANGUAGE plpgsql;
SELECT apply_tenant_isolation('Credential');
SELECT apply_tenant_isolation('App');
SELECT apply_tenant_isolation('AppLocalization');
-- ... ve diğerleri
-- BYPASS role for migrations + cron jobs
-- (sadece superuser veya special role kullanmalı)
CREATE ROLE gp_migration_admin BYPASSRLS;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gp_migration_admin;Her Prisma query'sinde, request başında set edilen AsyncLocalStorage context'inden tenantId çekilip Postgres'e push edilir:
// packages/db/src/tenantContext.ts
import { AsyncLocalStorage } from "node:async_hooks";
export interface TenantContext {
tenantId: string;
userId: string;
role: TenantRole;
requestId: string;
}
export const tenantStorage = new AsyncLocalStorage<TenantContext>();
export function getTenantContext(): TenantContext {
const ctx = tenantStorage.getStore();
if (!ctx) throw new Error("Tenant context not initialized — request did not pass through middleware");
return ctx;
}
export function getCurrentTenantId(): string {
return getTenantContext().tenantId;
}// packages/db/src/prisma.ts — connection wrapper
import { PrismaClient } from "@prisma/client";
import { tenantStorage } from "./tenantContext";
export function createPrismaClient(): PrismaClient {
const prisma = new PrismaClient({ log: ["error", "warn"] });
// Each query: SET LOCAL app.current_tenant = '<uuid>'
prisma.$use(async (params, next) => {
const ctx = tenantStorage.getStore();
if (ctx) {
// SET LOCAL = transaction-scoped; safe under connection pooling
await prisma.$executeRawUnsafe(`SET LOCAL app.current_tenant = '${ctx.tenantId}'`);
}
return next(params);
});
return prisma;
}Connection pooling sorunu: Prisma'nın pool'undaki bir connection birden fazla request tarafından serial olarak kullanılır.
SET LOCALtransaction-scoped olduğu için her tx'te yeniden set edilmelidir. Prisma$transactioniçinde SET LOCAL — middleware bunu garantili sağlar. Eğer$queryRawdirekt connection kullanırsan transaction'sız, RLS bypass edilir → bu yüzdenBYPASSRLSrole'ünü sadece migration ve admin cron için kullan.
Alternatif (V2 multi-region): PgBouncer transaction-mode'da
SET LOCALçalışır; statement-mode'da çalışmaz. Postgrespg_session_variablesextension'ı veya per-tenant connection pattern (Neon serverless gibi) daha güvenlidir.
Browser request
│
▼
[Next.js Route Handler]
│
├── 1. Parse session cookie → userId
├── 2. Load session.activeTenantId (DB)
├── 3. Verify TenantMember exists (user is member of activeTenant)
├── 4. Load membership.role
├── 5. Build TenantContext { tenantId, userId, role, requestId }
│
▼
[tenantStorage.run(ctx, async () => { ... })]
│
├── Business logic
│ ├── prisma.app.findMany() → middleware sets SET LOCAL → RLS filters
│ ├── redis.get(`cache:${getCurrentTenantId()}:apps`) → tenant-scoped cache
│ └── jobQueue.add('push', { tenantId: getCurrentTenantId(), ... })
│
▼
Response (with tenant-isolated data)
// apps/web/src/middleware.ts (Next.js middleware)
import { NextRequest, NextResponse } from "next/server";
export async function middleware(req: NextRequest) {
// Skip public routes
if (req.nextUrl.pathname.startsWith("/api/v1/auth") || req.nextUrl.pathname === "/login") {
return NextResponse.next();
}
const sessionCookie = req.cookies.get("gp_session")?.value;
if (!sessionCookie) return NextResponse.redirect(new URL("/login", req.url));
// Headers'a tenant context yaz (route handler okuyacak)
const session = await validateSession(sessionCookie);
if (!session) return NextResponse.redirect(new URL("/login", req.url));
const tenantId = session.activeTenantId;
if (!tenantId) return NextResponse.redirect(new URL("/onboarding/select-tenant", req.url));
const res = NextResponse.next();
res.headers.set("x-tenant-id", tenantId);
res.headers.set("x-user-id", session.userId);
res.headers.set("x-request-id", crypto.randomUUID());
return res;
}
export const config = {
matcher: [
"/((?!api/v1/auth|api/v1/healthz|login|signup|_next|favicon).*)",
],
};// apps/web/src/lib/withTenantContext.ts — route handler wrapper
import { tenantStorage } from "@marquee/db";
import { NextRequest } from "next/server";
export function withTenantContext<T>(
handler: (req: NextRequest) => Promise<T>
): (req: NextRequest) => Promise<T> {
return async (req) => {
const tenantId = req.headers.get("x-tenant-id");
const userId = req.headers.get("x-user-id");
const requestId = req.headers.get("x-request-id");
if (!tenantId || !userId) throw new Error("Missing tenant context — middleware did not run");
const membership = await prisma.tenantMember.findUnique({
where: { tenantId_userId: { tenantId, userId } },
});
if (!membership) throw new ForbiddenError("Not a member of this tenant");
return tenantStorage.run({ tenantId, userId, role: membership.role, requestId }, () => handler(req));
};
}
// Usage:
export const POST = withTenantContext(async (req) => {
// prisma queries otomatik tenant-scoped
const apps = await prisma.app.findMany(); // → RLS WHERE tenantId = current_tenant
return Response.json({ apps });
});Üç yaklaşım:
- Avantaj: Brand'leşmiş feel, cookie isolation kolay
- Dezavantaj: Wildcard TLS, DNS setup karmaşık, self-host overkill
- Avantaj: Tek domain, tek TLS, self-host friendly
- Dezavantaj: Cookie shared, biraz daha az "branded"
/login → public
/t/acme/dashboard → tenant=acme
/t/acme/apps → tenant=acme
/t/acme/apps/<uuid>/metadata → tenant=acme, app=<uuid>
/account/profile → user-level (tenant-agnostic)
/account/tenants → user'ın tüm tenants listesi
Self-host: Path otomatik /t/default/... redirect — kullanıcı görmez.
SaaS: Kullanıcı tenant switcher ile (Cmd+K) tenant değiştirir; path güncellenir.
V2 enterprise opsiyonu: Custom domain (acme.com → CNAME → bizim app), wildcard TLS Cloudflare ile.
Her cache key'in tenant prefix'i olmalı:
apple:token:<tenantId>:<credId>
google:token:<tenantId>:<credId>
ratelimit:user:<tenantId>:<userId>:<minute>
ratelimit:push-all:<tenantId>:<appId>:<minute>
lock:google-edit:<tenantId>:<packageName>
idempotency:<tenantId>:<key>
session:<sessionId> → değer JSON: {userId, activeTenantId, ...}
pub/sub channel: jobs:<tenantId>:<jobId>
Helper:
// packages/cache/src/keys.ts
import { getCurrentTenantId } from "@marquee/db";
export const cacheKey = {
appleToken: (credId: string) => `apple:token:${getCurrentTenantId()}:${credId}`,
googleToken: (credId: string) => `google:token:${getCurrentTenantId()}:${credId}`,
rateLimitUser: (userId: string, minute: number) =>
`ratelimit:user:${getCurrentTenantId()}:${userId}:${minute}`,
googleEditLock: (packageName: string) => `lock:google-edit:${getCurrentTenantId()}:${packageName}`,
idempotency: (key: string) => `idempotency:${getCurrentTenantId()}:${key}`,
jobsChannel: (jobId: string) => `jobs:${getCurrentTenantId()}:${jobId}`,
};Tenant silinince: redis.keys("*:<tenantId>:*") + DELETE (lifecycle cleanup job).
3 strateji:
- Tek queue, her job payload
{ tenantId, ... } - Worker tüm tenant'ları işler
- Risk: bir tenant queue'yu doldurursa diğerleri bekler ("noisy neighbor")
metadata-push:<tenantId>ayrı queue- İzole; ama 1000 tenant = 1000 queue → memory + connection patlar
- Tek queue
- Worker: aktif job'lardan tenant başına max N concurrent uygula (
bullmqJob.opts.groupveya manuel semaphore) - Fair-share scheduling
// packages/jobs/src/processors/metadata-push.ts
import { Worker } from "bullmq";
const TENANT_CONCURRENCY: Record<PlanTier, number> = {
FREE: 2, // 2 paralel push per tenant
PRO: 5,
TEAM: 10,
ENTERPRISE: 20,
};
const worker = new Worker("metadata-push", async (job) => {
const { tenantId } = job.data;
const plan = await getTenantPlan(tenantId);
const sem = getTenantSemaphore(tenantId, TENANT_CONCURRENCY[plan]);
await sem.acquire();
try {
return await tenantStorage.run({ tenantId, userId: job.data.userId, ... }, async () => {
return await processMetadataPush(job);
});
} finally {
sem.release();
}
}, {
concurrency: 50, // total worker concurrency
connection: redis,
});Per-tenant semaphore: Redis distributed semaphore (
redislockveya manual SETNX).
marquee-bucket/
└── tenants/
└── <tenantId>/
├── apps/
│ └── <appId>/
│ ├── screenshots/<id>/original.png
│ ├── screenshots/<id>/thumb-256.webp
│ ├── previews/<id>/original.mp4
│ └── android-images/<id>/...
├── credentials-meta/<credId>.json
├── master-json-exports/<exportId>.json.gz
└── builds/<jobId>/app.ipa
└── scratch/<tenantId>/<jobId>-<uuid>.tmp (1 saat TTL)
Signed URL generation: tenantId URL path'inde ZORUNLU + IAM policy ile bucket policy s3:prefix = tenants/${tenantId}/*.
S3 bucket policy (prod):
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::ACCT:role/gp-app-role" },
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::gp-bucket/tenants/${aws:PrincipalTag/tenantId}/*"
}Tenant-aware IAM opsiyonel (V2.5+); V1'de application-level enforcement yeterli.
~/.marquee/secrets/ (self-host)
└── tenants/
└── <tenantId>/
├── <credId>.p8
└── <credId>.json
AWS Secrets Manager:
SecretId: apps/marquee/<env>/tenants/<tenantId>/credentials/<credId>
IAM policy tenant prefix:
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:*:*:secret:apps/marquee/prod/tenants/${aws:PrincipalTag/tenantId}/*"
}Her audit event tenantId ile yazılır:
await prisma.auditEvent.create({
data: {
tenantId: getCurrentTenantId(), // ZORUNLU
userId,
appId,
action: "metadata.push",
target: `app:${appId}`,
diff: redactedDiff,
outcome: "SUCCESS",
requestId,
ipAddress,
userAgent,
},
});Cross-tenant audit view (admin only): Sadece OWNER role'ündeki sistem yöneticisi (yani "platform admin") tüm tenant'ları görebilir. Bu, normal tenant OWNER'dan farklı bir kavram — PlatformAdmin modeli V2:
model PlatformAdmin {
// SaaS sahibi (sen) — tüm tenant'ları görme yetkisi
userId String @unique @db.Uuid
user User @relation(fields: [userId], references: [id])
permissions String[] @default([]) // ["audit:read", "tenant:suspend"]
createdAt DateTime @default(now())
}PlatformAdmin RLS bypass için ayrı bir middleware path (/admin/*) — DB connection BYPASSRLS role'ü ile açılır. Çok dikkatli olunmalı, sadece audit endpoint'leri.
Saldırgan kendi tenant'ında, URL'de başka tenant'ın appId'sini deneyerek başkalarının data'sına erişmeye çalışır.
Savunma: RLS — prisma.app.findUnique({ where: { id: appId } }) başka tenant'ın app'ini dönemez (filter otomatik).
Test: 14_QA_TESTING.md 14.5.1 — "cross-tenant IDOR" test suite zorunlu, CI'da çalışır.
Saldırgan x-tenant-id header'ı manipüle ederek başka tenant'a switch yapmaya çalışır.
Savunma: Header middleware tarafından set edilir, kullanıcıdan asla okunmaz. Kullanıcı tenant switch isterse POST /api/v1/account/switch-tenant → server TenantMember doğrular → session'ı günceller.
BullMQ worker job payload'ında tenantId field'ı; saldırgan başka tenantId enjekte edebilir mi?
Savunma: Job enqueue API endpoint üzerinden; endpoint getCurrentTenantId() ile tenantId set eder. BullMQ Redis tarafında bypass mümkün ama Redis'e network erişim olmamalı (internal-only).
İki tenant aynı cache key'i hedeflerse data leak.
Savunma: Tüm key'ler cacheKey.X() helper'ından — manuel string concat YASAK. ESLint kuralı: redis.get/set direkt kullanımı banned.
Tenant A, tenants/B/secret-credentials path'ine erişmek için ../ enjekte edebilir mi?
Savunma: Path normalize + jail check (SecurityHelpers.cs C# kodundaki IsSafePath port). Storage abstraction objectStore.put(scopedPath, ...) her zaman tenants/<current>/... prefix'i kendisi ekler.
Tenant slug acme silindi; saldırgan aynı slug ile signup → eski path'lere erişim.
Savunma: Tenant silindiğinde slug soft-reserved 90 gün (yeni signup alamaz). Sonra slug pool'a döner.
Tenant A, support_url field'ına http://169.254.169.254/... (AWS metadata) yazar; sistem fetch ederse credential sızıntısı.
Savunma: URL preview/fetch yok V1'de. V1.5+ eklenirse SSRF allowlist + private IP blok.
Saldırgan /t/acme, /t/foo, /t/bar deneyerek varolan tenant slug'larını öğrenir.
Savunma: Var/yok ayrımı yapma — geçersiz tenant erişimi 401 Unauthorized (yetkisi yok mesajı). Slug enumeration için rate limit (10 farklı slug/dakika/IP).
SIGNUP OBSERVE SUSPEND/DELETE
│ │ │
▼ ▼ ▼
status=ACTIVE ACTIVE → ... ACTIVE → SUSPENDED (manual/billing)
trialEndsAt = +14gün ACTIVE → PENDING_DELETE (user request)
defaultTenantId set 30 gün grace → hard delete
member auto-create as OWNER (CASCADE her şey siler)
İlk başlangıçta seed.ts:
const defaultTenant = await prisma.tenant.upsert({
where: { slug: "default" },
create: {
slug: "default",
name: "My Studio",
deployedAs: "SELF_HOST",
status: "ACTIVE",
planTier: "ENTERPRISE", // self-host'ta limit yok
maxApps: 999, maxMembers: 999, maxPushesPerMonth: 9999,
},
update: {},
});
const owner = await prisma.user.upsert(...);
await prisma.tenantMember.upsert({
where: { tenantId_userId: { tenantId: defaultTenant.id, userId: owner.id } },
create: { tenantId: defaultTenant.id, userId: owner.id, role: "OWNER" },
update: {},
});POST /api/v1/auth/signup
body: { email, password, displayName, tenantName, tenantSlug }
↓
Transaction:
1. user = User.create()
2. tenant = Tenant.create({ slug, name, status: ACTIVE, trialEndsAt: +14d, planTier: FREE })
3. TenantMember.create({ tenantId, userId, role: OWNER })
4. Session.create({ userId, activeTenantId: tenant.id })
5. Stripe customer create (V2.1)
↓
Email verification + welcome email
1. UI: "Delete tenant" (OWNER only, type-to-confirm)
2. Set Tenant.status = PENDING_DELETE
3. 30 gün grace → cron job:
a. Cancel Stripe subscription
b. Delete object store: `s3 rm --recursive s3://bucket/tenants/<id>/`
c. Delete secrets: SecretProvider.deleteAll(tenantId)
d. Delete Redis: SCAN + DEL `*:<id>:*`
e. prisma.tenant.delete({ where: { id } }) → CASCADE her şeyi siler
f. Audit (PlatformAdmin scope): "tenant.deleted"
Bkz. 14_QA_TESTING.md. Özet:
- Unit: tenant context utility'leri (cacheKey, tenantStorage)
- Integration: RLS policy çalışıyor mu (mock Tenant A, query as Tenant B → 0 row)
- E2E (Playwright): 2 farklı tenant kullanıcısı paralel browser oturumu → A'nın değişikliği B'de görünmez
- Security: Cross-tenant IDOR attack suite (her endpoint için)
- Load: 100 tenant × 10 user simultaneous push → fair-share çalışıyor mu
Self-host kullanıcısı SaaS'a geçmek isterse:
# CLI tool: gp-export
gp-export --tenant default --out my-tenant-export.tar.gz
→ DB dump (tenant-scoped) + secrets (encrypted) + object store
# SaaS tarafında:
gp-import --tenant new-saas-tenant --file my-tenant-export.tar.gz
→ Mevcut tenant'a UPSERT, secrets ayrı upload promptÇift yönlü çalışır (SaaS'tan export → self-host'a import).
| Soru | Default | Tetikleyici alternatif |
|---|---|---|
| Bir user kaç tenant'ta üye olabilir? | Sınırsız | Plan limit'i (V2 Free=3) |
| Tenant slug değiştirilebilir mi? | Bir kez (signup'tan sonraki 7 gün) | Sonra OWNER ücretli plan ile |
| Cross-tenant veri paylaşımı? | Yok | "Shared workspace" V3 |
| Per-tenant subdomain? | V2 opsiyonel | Enterprise plan |
| Per-tenant DB (model C)? | V2.5 enterprise add-on | Müşteri talep ederse |
| Tenant import/export? | V1.5 | Hemen ihtiyaç yok |
| Tenant transfer (owner değişikliği)? | V1.5 | UI'da "Transfer ownership" flow |
| White-label (custom branding)? | V2.2 enterprise | Pazarlama gerekirse |