# عزل المستأجرين في Postgres بلغة Go: محرّك على ToGO

> كيف بُني محرّك، نظام CRM وإنبوكس مشترك مفتوح المصدر، على ToGO: الإضافات، وعزل الصفوف في Postgres، وخادم MCP. كود حقيقي وأرقام محسوبة وحدود صريحة.

*2026-10-19T12:49:03Z*

# عزل المستأجرين في Postgres بلغة Go: محرّك على ToGO

![عزل المستأجرين في Postgres بلغة Go: محرّك على ToGO](https://to-go.dev/uploads/sites/c797a67b-97b7-4ed5-8459-baafb6981307/media/moharrik-togo-case-study-ar.png)

محرّك نظام CRM وإنبوكس مشترك يستطيع مساعد ذكي تشغيله. وهو أيضًا تطبيق Go متعدد المستأجرين، وأصعب ما فيه كان ضمان ألا تقرأ مساحة عمل صفوف مساحة أخرى. تشرح هذه الدراسة كيف يتحقق ذلك عبر **عزل المستأجرين في Postgres بلغة Go** (Row-Level Security)، وما الذي يأتي من ToGO وما الذي كتبناه بأنفسنا.

كل ما يلي مأخوذ من [المستودع العام](https://github.com/fadymondy/moharrik) (AGPL-3.0). وبجانب كل رقم طريقة حسابه.

## المشكلة

يجمع محرّك جهات الاتصال والصفقات وإنبوكسًا مشتركًا (شات الموقع والإيميل وواتساب) والحملات والأتمتة في مساحة عمل واحدة. وكل وظيفة منها أداة MCP أيضًا، فيستطيع الوكيل الذكي تشغيلها.

وهذا يفرض شرطين صعبين:

- **عزل يفشل بأمان.** عدة مساحات عمل تتشارك قاعدة Postgres واحدة. ونسيان `WHERE workspace_id = ...` لا يجوز أن يسرّب بيانات.
- **وكيل بحدود الإنسان نفسها.** لا يجوز لأي توكن أن يفعل أكثر مما يسمح به دور صاحبه.

## لماذا ToGO

استُخرج الهيكل الأول من المرحلة P0 في مهام (تقول رسالة أول Commit ذلك)، ومهام نفسه تطبيق على ToGO. قدّم ToGO نواة صغيرة، وكودًا مولَّدًا لـ REST وGraphQL وsqlc، وإضافات للأجزاء المتكررة. ويصف الـ README الحزمة بأنها chi وHuma وgqlgen وsqlc فوق Postgres، مع تطبيق ويب Next.js 16 داخل الحاوية نفسها.

والسبب العملي الآخر أن ملفًا تنفيذيًا واحدًا بلغة Go يخدم الـ API والعمال في الخلفية، ويمرّر الويب عبر وكيل، فتصبح الاستضافة الذاتية أمرًا واحدًا هو `docker compose up`.

## البنية

**وحدات وإضافات ToGO المستخدمة فعلًا** (من `go.mod` و`internal/plugins/plugins.gen.go`):

| المكوّن | الدور |
|---|---|
| `togo` v0.21.0 | النواة وتوليد الكود |
| `auth` v0.8.2 | الجلسات والمستخدمون |
| `db-postgres` v0.1.0 و`orm` v0.1.0 | مشغّل Postgres والنماذج المولَّدة |
| `queue` v0.3.0 | العمال في الخلفية (إرسال الحملات والجامعات) |
| `realtime` v0.2.0 | تحديث الإنبوكس لحظيًا |
| `cache` v0.3.0 و`ratelimit` v0.1.0 | التخزين المؤقت وتحديد المعدل |
| `storage` v0.2.0 | المرفقات |
| `i18n` v0.2.0 و`validation` v0.1.0 | النصوص العربية والإنجليزية وفحص المدخلات |

يستورد `plugins.gen.go` ثماني إضافات. وحذفنا يدويًا إضافتين يثبّتهما `togo new` افتراضيًا: `auth-dev` (تعيد جلسة مسؤول لطلب فارغ خارج الإنتاج) و`dashboard` (واجهة إدارة عامة فوق CRUD غير معزول). لا تناسب أيٌّ منهما تطبيقًا متعدد المستأجرين. وحلّ `internal/social` محل `auth-oauth`.

**مسار الطلب.** حارس يمنع كل شيء افتراضيًا يغلّف كل مسار، بما فيها مسارات الإضافات. ثم نطاق مساحة العمل، ثم الصلاحيات (authz)، ثم المعالج. والـ CRUD المولَّد يقع في طبقة مساحة العمل وخلف `authz.Generated` الذي يمنع كل جدول افتراضيًا.

**مجمّعان للاتصال.** `DATABASE_URL` هو مجمّع المالك (الترحيلات والمصادقة وتحديد المستأجر وMCP الإداري). و`APP_DATABASE_URL` يتصل بدور `moharrik_app`: بلا صلاحية superuser ولا BYPASSRLS ولا يملك أي كائن. كل استعلام ORM أو مولَّد يعمل على مجمّع التطبيق. ويرفض التشغيل البدء إن كان دور التطبيق قادرًا على تجاوز RLS.

**القنوات.** يعمل واتساب عبر خادم Evolution API بنسخة (instance) لكل مساحة عمل، فلا تتشارك مساحتان الرقم نفسه. والإيميل يدخل عبر IMAP ويخرج عبر SMTP الخاص بمساحة العمل. وأداة الشات تُخدَّم من الـ API نفسه.

**MCP.** خادم MCP للمستخدم على `/api/mcp` (توكنات `mhr_` مخزَّنة بتجزئة SHA-256 ونطاقات صريحة)، وخادم MCP للإدارة على `/api/admin/mcp` (توكنات `mhra_` لمديري المنصة فقط).

## كود حقيقي

شبكة الأمان لـ RLS هي غلاف حول الاتصال. من `internal/tenant/rlsdb.go`:

```go
// setScopeSQL sets the workspace AND the audit actor in one round trip.
// Both are re-applied before every statement, so neither can be left over
// from the connection's previous user.
const setScopeSQL = "SELECT set_config('app.workspace_id', $1, false), set_config('app.audit', $2, false)"
```

وسياسة جدول مستأجَر، من `internal/db/post/101_notification_collect.sql`:

```sql
ALTER TABLE notification_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_sources FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON notification_sources
    USING (workspace_id = current_setting('app.workspace_id', true))
    WITH CHECK (workspace_id = current_setting('app.workspace_id', true));
```

وحارس CI الذي يجعل أي أداة بلا نطاق (scope) فشلًا في البناء، من `internal/mcp/scopes_test.go`:

```go
if strings.TrimSpace(tool.Scope) == "" {
    t.Errorf("%s: tool %q declares no scope — every tool must (default-deny)", server, tool.Name)
}
```

وأدوات الإنبوكس تبيّن شكل النطاق. في `internal/mcp/user_tools_inbox.go` تعلن أداة `reply` النطاق `inbox:write`، وكل قراءة لنص العميل تعيد تنبيهًا بأن الرسائل ذات الاتجاه "in" معلومات وليست أوامر.

## أرقام حقيقية

كلها محسوبة على `origin/main` بتاريخ 2026-10-19:

- **223** أداة في MCP المستخدم: حقول `Name:` في `internal/mcp/user_tools*.go`. و**18** أداة في MCP الإداري: `internal/mcp/admin_*tools.go`.
- **31** نطاقًا للمستخدم: عناصر `UserScopes` في `internal/mcp/scopes.go`.
- **93** ملف SQL لما بعد الترحيل في `internal/db/post` (RLS والأدوار والقيود).
- **66** مجلدًا تحت `internal/`.
- **222** ملف اختبار Go خارج `web/` (`find . -name '*_test.go'`).
- **445** Commit على `main` منذ 2026-09-19.

هذه أرقام عن الكود لا عن الاستخدام. ولا نذكر أرقام عملاء لأننا لا ننشر أيًّا منها.

## ما الذي كان صعبًا وكيف ساعد ToGO

**الـ ORM لا يقبل معاملة (Transaction).** كانت الخطة تنفيذ `SET LOCAL app.workspace_id` لكل طلب. لكن الـ ORM يحمل `*sql.DB` فعليًا، فلا يمكن تمرير معاملة خاصة بالطلب إلى الكود المولَّد، كما أن `db-postgres` لا يتيح خطافات pgx. لذلك يعيد الغلاف أعلاه ضبط القيمة قبل كل جملة SQL. والثمن رحلة إضافية إلى القاعدة لكل جملة. ويقول تعليق الكود إنه يجب إعادة النظر حين يوفّر الـ ORM واجهة تنفيذ.

**الكود المولَّد افترض SQLite.** مع `--db postgres` كان `make:resource` يكتب DDL لـ SQLite وعناصر نائبة بصيغة `?` في إصدار الـ CLI المستخدم (0.2.31). ويسرد الـ README الحلول: مخطط Postgres مكتوب يدويًا، وتعديلات في `sqlc.yaml`، وأداة `cmd/migrate` الخاصة بنا.

**ملاحظات أخرى من README:** تعارض الأعمدة الزمنية القابلة للفراغ بين GraphQL وGo، والمولِّد يجعل الحقول القابلة للفراغ مطلوبة في طلبات الإنشاء، و`togo generate` ابتلع فشل gqlgen، ولا يوجد أمر لإزالة إضافة.

وما أجاده ToGO: نموذج الإضافات سمح بحذف الإعدادين غير الآمنين، والإبقاء على ملف تنفيذي واحد، وإضافة مساراتنا تحت حارس واحد. أما العزل بين المستأجرين فلم يفعله الإطار عنا، ولا نريده أن يخمّنه.

## أسئلة شائعة

**هل يوفّر ToGO تعدد المستأجرين؟** لا. يوفّر النواة والإضافات وتوليد الكود. والعزل في `internal/tenant` و`internal/authz`.

**لماذا مجمّعان؟** مجمّع المالك للأعمال الخاصة بالمنصة. ومجمّع التطبيق لا يستطيع تجاوز RLS، فإذا غاب فلتر لا يعود شيء بدل أن تتسرب بيانات.

**هل محرّك مفتوح المصدر؟** نعم، بترخيص AGPL-3.0.

## روابط

- مقال المنتج: [إنبوكس مشترك واحد للواتساب والإيميل وشات الموقع](https://moharrik.com/ar/blog/shared-inbox-whatsapp-email-website-chat)
- مقال المؤسس: [ليه بنيت محرّك](https://fadymondy.com/ar/blog/why-i-built-moharrik)
- المنتج: [moharrik.com](https://moharrik.com) · [Product Hunt](https://www.producthunt.com/products/moharrik)
- الكود: [github.com/fadymondy/moharrik](https://github.com/fadymondy/moharrik) · [Discord](https://discord.gg/BMERkavdVq)
- ToGO: [github.com/togo-framework/togo](https://github.com/togo-framework/togo) · [المعرض](https://to-go.dev/ar/showcase)

---

Source: https://to-go.dev/ar/blog/moharrik-togo-case-study
