למה זה חשוב
WorkPilot היא SaaS multi-tenant. מאות ארגונים שונים חולקים את אותו DB ואותו שרת. הסיבה: עלויות נמוכות, deploy מהיר, scaling קל.
הסיבה הזו גם הסיכון. אם איזשהו באג מאפשר לארגון A לראות נתונים של ארגון B — זה אסון. הנה איך אנחנו מבטיחים שזה לא יכול לקרות.
הגישה: Defense in Depth
הקונספט פשוט: אל תסמוך על שכבה אחת בלבד. תוסיף הגנות בכל שכבה, כך שאם אחת נכשלת — אחרות יתפסו את הטעות.
ב-WorkPilot יש 5 שכבות הגנה:
שכבה 1: Session Cookie עם Validation
ה-cookie של המשתמש מכיל organizationId. אבל אנחנו לא סומכים על הקוקי כ-truth.
בכל request, הקוד עושה lookup ב-DB:
- האם המשתמש עדיין member של אותו ארגון?
- האם הארגון עדיין פעיל?
- האם ה-role שלו השתנה?
אם משהו השתנה — מתחדש fresh מה-DB. ה-cookie הוא רק רמז, לא אמת.
שכבה 2: AsyncLocalStorage עם Tenant Context
לפני שכל route handler רץ:
tenantStorage.run({ organizationId, userId, role }, () => handler())זה אומר שכל קוד שירוץ בתוך ה-handler יוכל לקרוא את ה-context בלי להעביר אותו פרמטר. הקסם: כל call ל-Prisma שיתבצע, יקבל את ה-tenant context אוטומטית.
שכבה 3: Prisma Extension — הזרקת WHERE
זו השכבה הכי חזקה. בנינו extension של Prisma שמיירט כל query על מודלים מולטי-טננטיים, ומזריק where: { organizationId } אוטומטית.
prisma.customer.findMany({ where: { isActive: true } }) הופך אוטומטית ל-prisma.customer.findMany({ where: { isActive: true, organizationId: '...' } }).
אין דרך לעקוף את זה — אפילו אם מפתח שכח להוסיף סינון, ה-extension יוסיף.
זה חל גם על: findUnique, findFirst, count, aggregate, groupBy, updateMany, deleteMany, ועל create (מזריק את ה-organizationId לתוך ה-data).
שכבה 4: Post-fetch Validation
חלק מה-queries (כמו findUnique עם ID ישיר) לא תומכים בהזרקת WHERE. במקרה הזה, ה-extension עושה fetch רגיל ואז מאמת שה-organizationId של התוצאה תואם ל-context. אם לא — מחזיר null.
זה תופס מקרים כמו: "מה אם משתמש מנחש ID של customer מארגון אחר ושולח אותו ב-URL?"
שכבה 5: API Route Wrapper
כל API route עטוף ב-withTenant():
1. בודק שהמשתמש מאומת
2. בודק שיש לו role המתאים
3. מגדיר את ה-AsyncLocalStorage
אין דרך ליצור route בלי ה-wrapper. אם מפתח שכח — TypeScript יצעק עליו.
מה אם מפתח שוכח?
נאמר שמישהו כתב route חדש בלי withTenant(). מה קורה?
1. TypeScript — אזהרה ב-compile time
2. Tenant context לא מוגדר — Prisma extension לא יכול להזריק WHERE
3. התוצאה: השאילתה תרוץ בלי סינון — כל ה-customers של כל הארגונים יחזרו
זה הסיכון הקריטי. כדי למנוע אותו לחלוטין, אנחנו:
- בדיקות אוטומטיות ב-CI שמוודאות שכל route תחת
/api/משתמש ב-withTenant - קוד reviews מקפידים על זה
- שמות מתודות — לא משתמשים ב-
prismaישיר, רק דרך ה-extended client
מה עוד אנחנו עושים
Audit Log
כל פעולה רגישה (DELETE, שינוי הרשאות) מתועדת עם userId, organizationId, IP, timestamp, ו-old/new values.
בדיקות תקופתיות
פעם בחודש סקריפט שעובר על כל הטבלאות עם organizationId ובודק:
- האם כל רשומה משויכת לארגון פעיל?
- האם יש foreign keys שמצביעים לרשומות מארגון אחר?
- האם יש memberships יתומים?
עד היום — אפס דליפות.
מה זה אומר לך כלקוח
זה אומר שאתה יכול לישון בשקט. הנתונים שלך:
- ✅ לא נראים על ידי ארגונים אחרים
- ✅ לא ניתנים למחיקה בטעות
- ✅ מגובים יומית
- ✅ מתועדים בכל פעולה רגישה
- ✅ עוברים על תשתית עם הצפנה ב-rest
רוצה לראות את ה-Audit Log שלך? בדשבורד → הגדרות → יומן פעילות.
סיכום
Multi-tenant security זה לא תכונה — זו ארכיטקטורה. ב-WorkPilot, יש לנו 5 שכבות הגנה שעובדות במקביל. כל אחת לבדה מספיקה. שילוב שלהן הופך את המערכת לבלתי-חדירה.
אם אתה בונה SaaS משלך — תיקח את הגישה הזו. לעולם אל תסמוך על שכבה אחת.