دليل تقني

تصميم أنظمة Multi-Tenant ما تنهار مع التوسع

كيف تصمم بنية multi-tenant مع عزل حقيقي. ثلاث طبقات حماية، نطاقات هرمية، أنماط RBAC، ودروس من بناء ثلاث أنظمة multi-tenant مختلفة.

28 فبراير 202620 دقيقة للقراءةفريق هندسة أورنتس

Multi-Tenancy مش قرار قاعدة بيانات

أغلب المقالات عن multi-tenancy تبدأ بسؤال قاعدة البيانات: قاعدة بيانات مشتركة، schema مشتركة، ولا قاعدة بيانات مخصصة لكل مستأجر؟ هذا المكان الغلط للبداية. نموذج قاعدة البيانات تفصيل تنفيذي. السؤال المعماري الحقيقي هو: كيف تفرض العزل على كل طبقة من النظام بحيث المستأجر A ما يقدر أبداً يشوف أو يعدل أو يأثر على بيانات المستأجر B؟

بنينا ثلاث أنظمة multi-tenant مختلفة. كل واحد حل العزل بطريقة مختلفة لأن كل واحد كان عنده قيود مختلفة. واحد يستخدم نطاقات هرمية بخمس مستويات هوية. الثاني يستخدم RBAC مسطح بأربع أدوار. والثالث يستخدم نطاق على مستوى المنظمة مع عزل القنوات. نموذج قاعدة البيانات كان أقل قرار مثير للاهتمام في الثلاثة.

هذا المقال يغطي الأنماط المعمارية اللي تخلي multi-tenancy آمن مع التوسع. للسياق الأوسع عن كيف نتعامل مع بنية الأنظمة، ذاك الدليل يغطي منهجيتنا. لأمثلة محددة على أنظمة AI متعددة المستأجرين، شوف أدلتنا عن التجارة الوكيلية وحوكمة الذكاء الاصطناعي.

نموذج الحماية بثلاث طبقات

عزل المستأجرين لازم يُفرض على ثلاث طبقات. إذا أي طبقة ناقصة، البيانات بتتسرب.

┌─────────────────────────────────────────────────┐
│  الطبقة 1: API MIDDLEWARE                        │
│  كل طلب يتم توثيقه وتحديد نطاقه                │
│  tenant_id يُستخرج من JWT/API key               │
│  يُحقن في سياق الطلب                            │
│                                                  │
├─────────────────────────────────────────────────┤
│  الطبقة 2: فلاتر الاستعلامات                     │
│  كل استعلام قاعدة بيانات يتضمن tenant_id        │
│  كل استعلام بحث محدد بنطاق المستأجر             │
│  ما في استعلام يشتغل بدون سياق المستأجر         │
│                                                  │
├─────────────────────────────────────────────────┤
│  الطبقة 3: تطبيق السياسات                        │
│  استدعاءات الأدوات تُفحص مقابل سياسات المستأجر  │
│  ذاكرة الوكيل محددة بنطاق المستأجر + الجلسة    │
│  المخرجات تُفلتر حسب قواعد ظهور المستأجر       │
│                                                  │
└─────────────────────────────────────────────────┘

الطبقة 1: API Middleware

كل طلب داخل لازم يتوثق ويُحدد نطاقه لمستأجر قبل ما يوصل لأي منطق أعمال.

// الـ middleware يستخرج سياق المستأجر من كل طلب
async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
    const token = req.headers.authorization?.replace('Bearer ', '');
    if (!token) return res.status(401).json({ error: 'No token provided' });

    const decoded = await verifyJwt(token);
    const tenant = await tenantStore.getById(decoded.tenant_id);

    if (!tenant || tenant.status !== 'active') {
        return res.status(403).json({ error: 'Tenant not found or suspended' });
    }

    // حقن سياق المستأجر في الطلب
    req.tenantContext = {
        tenantId: tenant.id,
        channelId: decoded.channel_id,
        role: decoded.role,
        permissions: decoded.permissions,
    };

    next();
}

سياق المستأجر مش اختياري. كل route handler، كل service method، كل استعلام قاعدة بيانات يستقبله. إذا دالة ما عندها سياق مستأجر، ما تقدر توصل لبيانات محددة بنطاق المستأجر.

الطبقة 2: فلاتر الاستعلامات

كل استعلام قاعدة بيانات لازم يتضمن نطاق المستأجر. هذا ما يُفرض بالاتفاق ("تذكر تضيف WHERE clause"). يُفرض بالبنية المعمارية.

// كلاس أساسي للـ repository يفرض نطاق المستأجر
class TenantScopedRepository<T> {
    async findMany(tenantId: string, filters: Partial<T>): Promise<T[]> {
        return this.db.query({
            TableName: this.tableName,
            KeyConditionExpression: 'tenant_id = :tid',
            FilterExpression: this.buildFilterExpression(filters),
            ExpressionAttributeValues: {
                ':tid': tenantId,
                ...this.buildFilterValues(filters),
            },
        });
    }

    async findById(tenantId: string, id: string): Promise<T | null> {
        const result = await this.db.get({
            TableName: this.tableName,
            Key: { tenant_id: tenantId, id },
        });
        return result.Item || null;
    }

    // ما في أي method يستعلم بدون tenant_id
    // الاستعلامات بين المستأجرين مستحيلة معمارياً
}

لمحركات البحث (OpenSearch، MeiliSearch، Elasticsearch)، كل استعلام يتضمن فلتر المستأجر:

async function searchProducts(tenantId: string, channelId: string, query: string) {
    return opensearch.search({
        index: 'products',
        body: {
            query: {
                bool: {
                    must: [{ match: { searchText: query } }],
                    filter: [
                        { term: { tenant_id: tenantId } },
                        { term: { channel_ids: channelId } },
                    ],
                },
            },
        },
    });
}

الطبقة 3: تطبيق السياسات

بالإضافة للوصول للبيانات، المستأجرين عندهم صلاحيات مختلفة لشو الإجراءات اللي يقدرون ينفذوها. طبقة السياسات تفحص هذي قبل أي إجراء يُنفذ.

interface TenantPolicy {
    tenant_id: string;
    rules: PolicyRule[];
}

interface PolicyRule {
    action: string;          // "create_order", "export_data", "use_ai_agent"
    effect: "allow" | "deny";
    conditions?: {
        max_value?: number;
        allowed_channels?: string[];
        require_approval?: boolean;
    };
}

// فحص السياسة قبل أي إجراء
async function checkPolicy(tenantId: string, action: string, params: any): Promise<boolean> {
    const policy = await policyStore.getForTenant(tenantId);
    const matchingRules = policy.rules.filter(r => r.action === action);

    // قواعد المنع لها الأولوية
    if (matchingRules.some(r => r.effect === 'deny')) return false;

    // ما في قاعدة سماح مطابقة = مرفوض (المنع الافتراضي)
    const allowRule = matchingRules.find(r => r.effect === 'allow');
    if (!allowRule) return false;

    // فحص الشروط
    if (allowRule.conditions?.max_value && params.value > allowRule.conditions.max_value) {
        return false;
    }

    return true;
}

النطاقات الهرمية مقابل المسطحة

النطاق المسطح (SaaS بسيط)

كل مورد ينتمي لمستأجر واحد بالضبط. بدون مستويات فرعية.

Tenant A
  ├── Users (owner, admin, member, viewer)
  ├── Products
  ├── Orders
  └── Settings

Tenant B
  ├── Users
  ├── Products
  ├── Orders
  └── Settings

مناسب لـ: منتجات SaaS حيث كل عميل هو مساحة عمل معزولة. فكر بأدوات إدارة المشاريع، أنظمة CRM، لوحات التحكم الداخلية.

النطاق المسطح يحتاج أربع مستويات أدوار:

الدورالصلاحيات
Ownerوصول كامل، الفوترة، حذف المستأجر
Adminإدارة المستخدمين، الإعدادات، كل البيانات
Memberإنشاء وتعديل بياناته، عرض البيانات المشتركة
Viewerوصول للقراءة فقط للبيانات المشتركة

النطاق الهرمي (المنصات المؤسسية)

الموارد تُحدد نطاقها عبر مستويات متعددة. كل مستوى يضيق الرؤية.

Tenant (منظمة التاجر)
  └── Channel (واجهة المتجر، API، widget)
       └── Supplier Binding (أي موردين مرئيين لكل قناة)
            └── Customer (المستخدم النهائي داخل القناة)
                 └── Session (جلسة المتصفح/الجهاز)
                      └── Agent Thread (محادثة AI واحدة)

مناسب لـ: منصات السوق، التجارة متعددة العلامات التجارية، الأنظمة المؤسسية حيث منظمة واحدة عندها واجهات متاجر متعددة، قنوات بيع، أو علامات تجارية فرعية.

كل مستوى يضيف فلتر. منتج مرئي في Channel A مش بالضرورة مرئي في Channel B، حتى ضمن نفس المستأجر. عميل في Channel A ما عنده وصول لبيانات Channel B. و thread للوكيل في جلسة واحدة ما يقدر يشوف محادثات من جلسة ثانية.

// السياق الهرمي يُمرر عبر كل عملية
interface TenantContext {
    tenantId: string;        // المنظمة
    channelId: string;       // واجهة المتجر أو قناة البيع
    customerId?: string;     // المستخدم النهائي (إذا موثق)
    sessionId?: string;      // جلسة المتصفح
    threadId?: string;       // thread محادثة AI
}

// استعلام محدد بنطاق التسلسل الهرمي الكامل
async function getVisibleProducts(ctx: TenantContext) {
    const channel = await channelStore.get(ctx.tenantId, ctx.channelId);
    return productStore.findMany({
        tenant_id: ctx.tenantId,
        supplier_id: { $in: channel.visibleSupplierIds },
        status: 'active',
    });
}

النطاق الهجين

بعض الأنظمة تحتاج نطاق مسطح لأغلب الموارد لكن نطاق هرمي لميزات محددة. مثلاً، تثبيت Vendure للتجارة ممكن يستخدم نطاق مسطح (مستأجر واحد لكل متجر) لكن نطاق على أساس القنوات لرؤية المنتجات والتسعير.

// نطاق القنوات في Vendure
async findByCustomer(ctx: RequestContext, customerId: number) {
    return this.connection.getRepository(ctx, CiWishlist).find({
        where: {
            customerId,
            channelId: ctx.channelId,  // نطاق القناة داخل المستأجر
        },
    });
}

لمعرفة أكثر عن كيف ننفذ نطاق القنوات في Vendure، شوف دليل بنية Vendure الإنتاجية.

أنماط Auth و RBAC

JWT-Based Tenant Scoping

الـ JWT token يحمل هوية المستأجر. كل طلب API يتضمنه.

// بنية JWT payload
interface TenantJwtPayload {
    sub: string;              // معرف المستخدم
    tenant_id: string;        // أي مستأجر
    channel_id?: string;      // أي قناة (إذا قابل للتطبيق)
    role: string;             // owner | admin | member | viewer
    permissions: string[];    // صلاحيات دقيقة
    iat: number;
    exp: number;
}

الـ tenant_id في JWT هو آلية تحديد النطاق الأساسية. يُعيّن وقت تسجيل الدخول وما يقدر يتغير بدون إعادة التوثيق. الـ backend يستخرجه من كل طلب ويستخدمه لتحديد نطاق كل الوصول للبيانات.

توثيق API Key

للتواصل بين الأنظمة (تكامل ERP، خدمات خارجية، webhooks)، مفاتيح API تُربط بمستأجرين:

async function apiKeyMiddleware(req: Request, res: Response, next: NextFunction) {
    const apiKey = req.headers['x-api-key'];
    if (!apiKey) return next(); // يمرر لتوثيق JWT

    const keyRecord = await apiKeyStore.findByKey(apiKey);
    if (!keyRecord || keyRecord.status !== 'active') {
        return res.status(401).json({ error: 'Invalid API key' });
    }

    req.tenantContext = {
        tenantId: keyRecord.tenantId,
        channelId: keyRecord.channelId,
        role: keyRecord.role,
        permissions: keyRecord.permissions,
    };

    next();
}

مفاتيح API محددة بنطاق المستأجر. تدوير المفاتيح ما يغير ربط المستأجر. حدود المعدل ونطاقات الصلاحيات لكل مفتاح، مش لكل مستأجر.

دقة الصلاحيات

الأدوار تحدد مستويات الوصول العامة. الصلاحيات تحدد القدرات المحددة:

const PERMISSIONS = {
    // إدارة المنتجات
    PRODUCT_READ: 'product:read',
    PRODUCT_CREATE: 'product:create',
    PRODUCT_UPDATE: 'product:update',
    PRODUCT_DELETE: 'product:delete',

    // إدارة الطلبات
    ORDER_READ: 'order:read',
    ORDER_CREATE: 'order:create',
    ORDER_CANCEL: 'order:cancel',
    ORDER_REFUND: 'order:refund',

    // ميزات AI
    AI_AGENT_USE: 'ai:agent:use',
    AI_AGENT_CONFIGURE: 'ai:agent:configure',
    AI_EXPORT: 'ai:export',

    // إدارة
    USER_MANAGE: 'user:manage',
    SETTINGS_MANAGE: 'settings:manage',
    BILLING_MANAGE: 'billing:manage',
};

// ربط الأدوار بالصلاحيات
const ROLE_PERMISSIONS = {
    owner: Object.values(PERMISSIONS),
    admin: Object.values(PERMISSIONS).filter(p => p !== 'billing:manage'),
    member: ['product:read', 'product:create', 'product:update', 'order:read', 'order:create', 'ai:agent:use'],
    viewer: ['product:read', 'order:read'],
};

شو يصير لما النطاق يفشل

أفضل طريقة لفهم ليش الحماية بثلاث طبقات مهمة هي أنك تشوف شو ينكسر لما كل طبقة تكون ناقصة.

الطبقة الناقصةشو يصيرمثال حقيقي
بدون API middlewareأي طلب بـ JWT صالح يقدر يوصل لبيانات أي مستأجر بتخمين tenant IDsمنافس يسحب كتالوج منتجات عميلك
بدون فلاتر الاستعلاماتمطور ينسى WHERE clause في endpoint جديد، بيانات بين المستأجرين تتسربلوحة تحكم الإدارة تعرض كل العملاء عبر كل المستأجرين
بدون تطبيق السياساتمستأجر على خطة "starter" يوصل لميزات "enterprise" عبر استدعاءات API مباشرةمستأجر بالمجان يصدر بيانات بلا حدود، يتجاوز قيود الخطة

السيناريو الأخطر: الطبقات الثلاث تشتغل للقراءة لكن مش للكتابة. المستأجر A ما يقدر يشوف بيانات المستأجر B، لكن bug في endpoint التحديث يخلي المستأجر A يكتب فوق أسعار منتجات المستأجر B. اكتشفنا هذا في الاختبار. في الإنتاج، كان ممكن يكون كارثي.

اختبار عزل Multi-Tenant

اختبار multi-tenancy يحتاج أنماط اختبار محددة أغلب test suites ما تغطيها.

اختبار الوصول بين المستأجرين

لكل endpoint، اختبر أن المستأجر A ما يقدر يوصل لبيانات المستأجر B:

describe('Tenant isolation', () => {
    it('tenant A cannot read tenant B products', async () => {
        // إنشاء منتج كـ tenant B
        const product = await createProduct(tenantB.token, { name: 'Secret Product' });

        // محاولة قراءته كـ tenant A
        const response = await api.get(`/products/${product.id}`, {
            headers: { Authorization: `Bearer ${tenantA.token}` },
        });

        expect(response.status).toBe(404); // مش 403، مش 200 ببيانات فارغة
    });

    it('tenant A cannot update tenant B products', async () => {
        const product = await createProduct(tenantB.token, { name: 'Original' });

        const response = await api.patch(`/products/${product.id}`, {
            headers: { Authorization: `Bearer ${tenantA.token}` },
            body: { name: 'Hacked' },
        });

        expect(response.status).toBe(404);

        // التحقق أن المنتج ما تعدل
        const check = await api.get(`/products/${product.id}`, {
            headers: { Authorization: `Bearer ${tenantB.token}` },
        });
        expect(check.body.name).toBe('Original');
    });
});

ارجع 404 (مش 403) لمحاولات الوصول بين المستأجرين. الـ 403 يأكد أن المورد موجود، وهذا بحد ذاته تسريب معلومات.

اختبار عزل البحث

تأكد أن نتائج البحث محددة بنطاق المستأجر:

it('search results are tenant-scoped', async () => {
    await createProduct(tenantA.token, { name: 'Widget Alpha' });
    await createProduct(tenantB.token, { name: 'Widget Beta' });
    await waitForSearchIndex();

    const results = await api.get('/search?q=Widget', {
        headers: { Authorization: `Bearer ${tenantA.token}` },
    });

    expect(results.body.items).toHaveLength(1);
    expect(results.body.items[0].name).toBe('Widget Alpha');
    // Widget Beta ما لازم يظهر
});

اختبار العمليات الجماعية

تأكد أن العمليات الجماعية (التصدير، الاستيراد، التحديثات الدفعية) تحترم حدود المستأجرين:

it('export only includes own tenant data', async () => {
    const exportResult = await api.post('/export/products', {
        headers: { Authorization: `Bearer ${tenantA.token}` },
    });

    const exportedIds = exportResult.body.products.map(p => p.id);
    const tenantBProducts = await getAllProducts(tenantB.token);
    const tenantBIds = tenantBProducts.map(p => p.id);

    // ما في أي product IDs لـ tenant B في تصدير tenant A
    const overlap = exportedIds.filter(id => tenantBIds.includes(id));
    expect(overlap).toHaveLength(0);
});

لمعرفة كيف نتعامل مع الاختبار بشكل أوسع، شوف دليل هندسة البرمجيات.

البنية التحتية المشتركة مقابل المخصصة

النموذجمتى تستخدمهالمقايضات
مشترك بالكامل (قاعدة بيانات واحدة، schema واحدة)SaaS مع مستأجرين صغار كثيرينالأرخص. الأصعب للعزل. خطر الجار المزعج.
قاعدة بيانات مشتركة، schemas منفصلةمستأجرين متوسطين يحتاجون عزل منطقيعزل جيد. تعقيد أكثر في الـ migrations.
قواعد بيانات مخصصةمستأجرين مؤسسيين بمتطلبات امتثالأفضل عزل. الأغلى. الأصعب في الإدارة.
clusters مخصصةصناعات منظمة (رعاية صحية، مالية)عزل كامل. أعلى تكلفة. عمليات منفصلة لكل مستأجر.

لأغلب تطبيقات SaaS، المشترك بالكامل مع الحماية على مستوى التطبيق (نموذج الطبقات الثلاث فوق) هو الخيار الصح. أبسط في التشغيل، أرخص في التكلفة، وإذا طبقات الحماية صحيحة، آمن بنفس الدرجة.

البنية التحتية المخصصة تصير ضرورية لما المستأجرين عندهم متطلبات تنظيمية تفرض عزل فيزيائي (مثلاً، البيانات لازم تبقى في دولة محددة)، أو لما حجم عمل مستأجر واحد كبير لدرجة يأثر على الباقي (مشكلة الجار المزعج).

أخطاء شائعة

  1. التعامل مع multi-tenancy كمشكلة قاعدة بيانات. نموذج قاعدة البيانات (مشترك مقابل مخصص) هو أقل قرار أهمية. نموذج الحماية (ثلاث طبقات) هو الأهم.

  2. فرض العزل بالاتفاق. "المطورين لازم دايماً يضيفون tenant_id في الاستعلامات" مش استراتيجية. خل الاستعلام بدون سياق المستأجر مستحيل معمارياً.

  3. إرجاع 403 للوصول بين المستأجرين. ارجع 404. الـ 403 يأكد أن المورد موجود، وهذا يسرب معلومات بين المستأجرين.

  4. ما في اختبارات وصول بين المستأجرين. كل endpoint يحتاج اختبار يتحقق أن المستأجر A ما يقدر يوصل لبيانات المستأجر B. للقراءة والكتابة.

  5. نسيان عزل فهرس البحث. استعلامات قاعدة البيانات ممكن تكون محددة النطاق، لكن إذا فهرس البحث ما يُفلتر بالمستأجر، نتائج البحث تتسرب بين المستأجرين.

  6. caches مشتركة بدون بادئة مفتاح المستأجر. إذا مفتاح Redis cache هو product:123، مشترك بين المستأجرين. استخدم tenant_abc:product:123.

  7. background jobs بدون سياق المستأجر. وظيفة مجدولة تعالج "كل الطلبات المعلقة" بدون تحديد نطاق المستأجر تعالج طلبات كل المستأجرين في دفعة واحدة. مرر سياق المستأجر عبر job payloads.

  8. ما في rate limiting لكل مستأجر. استيراد جماعي لمستأجر واحد ما لازم يأثر على أداء كل المستأجرين الثانيين. حدد المعدل لكل مستأجر، مش بس لكل IP.

النقاط الرئيسية

  • الحماية بثلاث طبقات مش قابلة للتفاوض. API middleware، فلاتر الاستعلامات، وتطبيق السياسات. الثلاثة. كل طلب، كل استعلام، كل إجراء.

  • النطاقات الهرمية تتعامل مع التعقيد المؤسسي. النطاق المسطح يشتغل لـ SaaS بسيط. المنصات المؤسسية تحتاج نطاق على مستوى المستأجر، القناة، العميل، الجلسة، والـ thread.

  • خل الوصول بين المستأجرين مستحيل معمارياً. لا تعتمد على المطورين يتذكرون WHERE clauses. كلاسات repository أساسية تتطلب tenant_id كـ parameter إلزامي.

  • اختبر العزل بشكل صريح. كل endpoint يحتاج اختبار وصول بين المستأجرين. للقراءة، الكتابة، البحث، التصدير، والعمليات الجماعية.

  • ارجع 404، مش 403. محاولات الوصول بين المستأجرين لازم تبان كأن المورد مش موجود، مش كأن المستخدم ما عنده صلاحية.

  • البنية التحتية المشتركة مع العزل على مستوى التطبيق تشتغل لأغلب الحالات. البنية التحتية المخصصة للمتطلبات التنظيمية أو مشاكل الجار المزعج، مش للأمان.

نطبق هذي الأنماط عبر خدمات الذكاء الاصطناعي، مشاريع البرمجيات المخصصة، ومنصات التجارة الإلكترونية. إذا تصمم نظام multi-tenant، تواصل مع فريقنا أو اطلب عرض سعر. تقدر كمان تستكشف صفحة الحلول ونهجنا في الثقة والامتثال لكيف نتعامل مع ضمانات عزل المستأجرين.

المواضيع المغطاة

بنية multi-tenantعزل المستأجريننطاق البياناتبنية SaaSتصميم multi-tenancyأمان المستأجرينRBAC متعدد المستأجرينعزل البيانات

جاهز لبناء أنظمة ذكاء اصطناعي جاهزة للإنتاج؟

فريقنا متخصص في بناء أنظمة ذكاء اصطناعي جاهزة للإنتاج. خلينا نحكي كيف نقدر نساعد.

ابدأ محادثة