> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# OPA عملياً: سياسات ABAC لخدماتك وبواباتك
- URL: https://arabroot.io/articles/opa-عمليا-سياسات-abac-لخدماتك-وبواباتك/
- Published: 2026-10-13T00:00:00.000Z
- Updated: 2026-10-12T15:51:46.000Z
- Description: كيف تكتب سياسات ABAC بلغة Rego وتختبرها، ثم توزعها موقعة على خدماتك وبواباتك وتسجل كل قرار تتخذه.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الأمن السيبراني, الاستضافة الذاتية

في [منصة صلاحيات مركزية لخدماتك](https://arabroot.io/articles/%D9%85%D9%86%D8%B5%D8%A9-%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%D9%83-microservices/) استخدمنا محرك علاقات لأن المشاركة والمجلدات علاقات، ولكن كثيراً من القواعد في الخدمات ليست علاقات وإنما خصائص: موظف الدعم يرد المبلغ إذا كان الطلب في منطقته، وكان المبلغ تحت حده، وكان الطلب قد سلم، ولم يكن الطلب طلبه هو، وكان ذلك في ساعات العمل. وهذه القاعدة هي ما يسمى الصلاحيات حسب الخصائص ABAC التي عرفناها في [المقال الأول](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/#abac)، ومكانها محرك سياسات، وفي هذا المقال نأخذ OPA من أول سياسة إلى الإنتاج: الاختبارات، والتوزيع الموقع، وسجل القرارات، والبوابات، والأداء.

المواضيع في السلسلة:

- [الصلاحيات المركزية Authorization](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/): النماذج والمصطلحات والمحركات.
- [منصة صلاحيات مركزية لخدماتك](https://arabroot.io/articles/%D9%85%D9%86%D8%B5%D8%A9-%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%D9%83-microservices/): المنصة والمزامنة والاتساق.
- [OpenFGA عملياً](https://arabroot.io/articles/openfga-%D8%B9%D9%85%D9%84%D9%8A%D8%A7-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D8%A7%D9%84%D8%B9%D9%84%D8%A7%D9%82%D8%A7%D8%AA-%D9%85%D9%86-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%A7%D9%86%D8%AA%D8%A7%D8%AC/): نموذج العلاقات.
- OPA عملياً (الموضوع الحالي).

وقد كتبنا في المقال الأول سياسة صغيرة بلغة Rego وشغلنا OPA كسيرفر ورأينا سجل القرارات على الشاشة، فلن نعيد ذلك هنا، وسوف نناقش في هذا المقال ما يلي:

- لماذا اخترنا OPA لهذا المقال وليس Cerbos، ومتى يكون Cerbos هو الأنسب.
- سياسة ABAC حقيقية للمرتجعات مع اختباراتها، وفخ قائمة الرفض الذي جعل طلباً ناقص البيانات يمر في أول نسخة كتبناها.
- بناء الحزمة Bundle وتوقيعها وتوزيعها على عدة نسخ من OPA، وما يحدث عندما يعدل أحد الحزمة في الطريق.
- سجل القرارات مع إخفاء البيانات الشخصية، وربط OPA ببوابة APISIX وبEnvoy، وما يحدث عندما يتوقف المحرك.
- قياس الأداء، وتحويل السياسة إلى شرط SQL لترشيح القوائم.

## OPA أم Cerbos؟

المحركان بترخيص Apache-2.0، وكلاهما يناسب ABAC، وقد عرفناهما في [مقارنة المحركات](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/#engines). والسؤال الذي حسم الاختيار لهذا المقال هو: هل تريد المحرك نفسه في الخدمات وفي البوابات، بأدوات مفتوحة المصدر فقط؟

| المعيار          | OPA                                                    | Cerbos                                                                                                |
| ---------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| اللغة            | Rego، عامة لأي JSON                                    | YAML مع شروط CEL، مصممة لصلاحيات التطبيقات                                                            |
| ترشيح القوائم    | Compile API يحول السياسة إلى شرط SQL منذ الإصدار 1.9.0 | واجهة PlanResources تعطي خطة استعلام                                                                  |
| بوابة APISIX     | إضافة opa مدمجة في APISIX                              | لا توجد إضافة مدمجة                                                                                   |
| بوابة Envoy      | إضافة opa-envoy-plugin مفتوحة المصدر                   | عبر Cerbos Synapse الذي يحتاج مفتاح ترخيص                                                             |
| التوزيع والسجلات | حزم موقعة وسجل قرارات وتقارير حالة في المشروع نفسه     | قراءة السياسات من القرص أو Git أو تخزين الكائنات أو قاعدة بيانات، وسجل تدقيق، وCerbos Hub خدمة تجارية |
| AuthZEN          | غير مدمج                                               | واجهات /access/v1/evaluation مدمجة                                                                    |

وهذه المعلومات من [توثيق إضافة opa في APISIX](https://apisix.apache.org/docs/apisix/plugins/opa/?ref=arabroot.io) و[مستودع opa-envoy-plugin](https://github.com/open-policy-agent/opa-envoy-plugin?ref=arabroot.io) و[واجهات Cerbos](https://docs.cerbos.dev/cerbos/latest/api/?ref=arabroot.io) و[صفحة Synapse](https://docs.cerbos.dev/synapse/latest/?ref=arabroot.io) التي تذكر دعم Envoy ومفاتيح الترخيص، وقد راجعناها في أكتوبر 2026\. لذلك اخترنا OPA، لأن المنصة في هذه السلسلة فيها بوابة APISIX، ونريد القواعد العامة في البوابة والقواعد الدقيقة في الخدمة بلغة واحدة. أما إذا كانت سياساتك داخل الخدمات فقط، ويكتبها أو يراجعها أشخاص من خارج فريق التطوير، فإن Cerbos بملفات YAML وأدواره المشتقة Derived Roles أسهل في القراءة، وهو الأنسب في هذه الحالة.

ويجدر الإشارة هنا إلى تغيير مهم في من يقف خلف OPA: في 20 أغسطس 2025 انتقل منشئو OPA وعدد من فريق Styra إلى Apple، وقال المشرفون في [رسالتهم للمجتمع](https://openpolicyagent.org/blog/note-from-teemu-tim-and-torin-to-the-open-policy-agent-community-2dbbfe494371?ref=arabroot.io) إن OPA يبقى مشروعاً متخرجاً Graduated في CNCF دون أي تغيير في الحوكمة أو الترخيص، وإن توزيعة Styra التجارية EOPA وOPA Control Plane والأداة Regal تنتقل إلى منظمة OPA مفتوحة المصدر. ومن نتائج ذلك أن ترشيح البيانات إلى SQL الذي كان في EOPA وحدها نقل إلى OPA في [الإصدار 1.9.0](https://github.com/open-policy-agent/opa/releases/tag/v1.9.0?ref=arabroot.io) في 26 سبتمبر 2025.

## مكونات التجربة

نفذنا التجربة على Docker Engine 29.8.0 بالإصدارات: OPA 1.21.1 وopa-envoy-plugin 1.21.1 وEnvoy 1.39.3 وAPISIX 3.19.0 وRegal 0.43.0 وNginx 1.31.6 وPostgreSQL 18.6 وPython 3.13.16، وكل ما ننشئه يبدأ اسمه بالبادئة `lab-authz-opa`. والمكونات: سيرفر ويب يقدم الحزمة، ونسخة OPA تسألها خدمة المرتجعات وAPISIX وترسل سجلاتها إلى جامع صغير، ونسخة OPA أخرى فيها إضافة Envoy، وتطبيق بسيط خلف البوابتين، وPostgreSQL لترشيح القوائم، وعميلان: واحد في شبكة «المكتب» وواحد خارجها. نبدأ بالمجلدات:

```bash
mkdir -p ~/opa-lab/{policy/refunds,policy/gateway,policy/envoy,policy/filters,policy/system,bundles,keys,apisix,envoy}
cd ~/opa-lab
```

## سياسة المرتجعات

القاعدة كما وصفها فريق الدعم: يرد موظف الدعم المبلغ إذا كان الطلب في منطقته، وسلم للعميل، والمبلغ لا يزيد على حد مستواه، ولم يكن الطلب طلبه الشخصي، وكان ذلك في ساعات العمل بتوقيت الرياض، وإذا زاد المبلغ على ألف ريال فيجب أن يكون دخوله بمصادقة ثنائية MFA. والخصائص تأتي من ثلاثة مصادر: المستخدم من حقول التوكن، والطلب من قاعدة بيانات الخدمة، والوقت من الخدمة نفسها. وهذا الملف `policy/refunds/refunds.rego` في نسخته النهائية:

```rego
# Who may refund an order in the support service (ABAC: attributes of the user, the order and the request).
package refunds

import rego.v1

default allow := false

# The user's attributes come from the token claims the service passes in input.user,
# the order's attributes from the service's own database in input.order.
allow if {
	count(deny) == 0
	input.action == "refund"
}

# A deny list fails open when an attribute is missing (the rule is undefined, so it adds nothing).
# These rules turn every missing attribute into a reason to refuse.
required := {
	"user": {"groups", "region", "level", "customer_id", "amr"},
	"order": {"region", "status", "amount", "customer_id"},
}

deny contains sprintf("missing %s.%s", [part, key]) if {
	some part, keys in required
	some key in keys
	not key in object.keys(object.get(input, part, {}))
}

deny contains "missing request time" if not input.now

deny contains "no refund limit for this level" if not data.refunds.limits[input.user.level]

deny contains "not a support agent" if not "support" in input.user.groups

deny contains "order in another region" if input.user.region != input.order.region

deny contains "order not delivered yet" if input.order.status != "delivered"

deny contains "amount above your refund limit" if input.order.amount > data.refunds.limits[input.user.level]

deny contains "cannot refund your own order" if input.order.customer_id == input.user.customer_id

deny contains "large refund needs MFA" if {
	input.order.amount > data.refunds.mfa_above
	not "mfa" in input.user.amr
}

deny contains "outside business hours" if {
	hour := time.clock([time.parse_rfc3339_ns(input.now), "Asia/Riyadh"])[0]
	not hour in numbers.range(8, 19)
}

# One object the service asks for: the decision and the reasons behind it.
decision := {"allow": allow, "reasons": deny}
```

والحدود في ملف بيانات منفصل `policy/refunds/data.json`، حتى يغيرها فريق الدعم دون أن يلمس القواعد:

```json
{
  "limits": { "agent": 500, "senior": 3000, "lead": 20000 },
  "mfa_above": 1000
}
```

في السياسة أعلاه لاحظ التالي:

- السياسة مكتوبة كقائمة أسباب للرفض `deny`، والسماح يعني ألا يوجد أي سبب، وميزة هذا الشكل أن الخدمة تحصل مع كل رفض على السبب، فتعرضه للموظف وتكتبه في السجل.
- `amr` حقل معرف في OpenID Connect يذكر طرق المصادقة التي استخدمها المستخدم، وقد شرحنا التوكن وحقوله في [شرح OpenID Connect](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#oidc)، وهو مثال على خاصية لا يعرفها إلا مزود الهوية، فتأكد أن مزودك يضعه في التوكن قبل أن تبني عليه قاعدة.
- الساعة تحسب بتوقيت `Asia/Riyadh` صراحة، لأن الوقت في الطلب بتوقيت UTC، والساعة الثامنة صباحاً في الرياض هي الخامسة بتوقيت UTC، وقد شرحنا هذا الخلط في [التاريخ والوقت للمطورين](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%A7%D8%B1%D9%8A%D8%AE-%D9%88%D8%A7%D9%84%D9%88%D9%82%D8%AA-utc-%D9%88%D8%A7%D9%84%D9%85%D9%86%D8%A7%D8%B7%D9%82-%D8%A7%D9%84%D8%B2%D9%85%D9%86%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D8%A7%D9%84%D9%87%D8%AC%D8%B1%D9%8A/).
- الحدود تقرأ من `data.refunds.limits`، لأن ملف البيانات داخل المجلد `refunds`. وفي النسخة الأولى كتبنا `data.limits` ونجحت الاختبارات لأننا شغلناها من داخل المجلد، ثم نبهنا Regal إلى مرجع غير معرف Unresolved Reference، فعند بناء الحزمة من المجلد `policy` يصبح المسار `data.refunds.limits`، وكانت كل حدود المبالغ ستختفي بصمت.

## الاختبارات، وفخ قائمة الرفض

الاختبارات في Rego قواعد يبدأ اسمها ب `test_`، وقد رأينا شكلها في [المقال الأول](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/#lab-opa-test)، وهذا الملف `policy/refunds/refunds_test.rego`، وفيه اختبار لكل قاعدة واختبار لطلب ينقصه معظم البيانات:

```rego
package refunds_test

import rego.v1

import data.refunds

agent := {"groups": ["support"], "region": "riyadh", "level": "agent", "customer_id": "c-9", "amr": ["pwd"]}

order := {"region": "riyadh", "status": "delivered", "amount": 300, "customer_id": "c-1"}

req(user, ord) := {"action": "refund", "user": user, "order": ord, "now": "2026-10-12T10:00:00Z"}

test_agent_refunds_small_order if refunds.allow with input as req(agent, order)

test_other_region_denied if {
	not refunds.allow with input as req(agent, object.union(order, {"region": "jeddah"}))
}

test_limit_by_level if {
	not refunds.allow with input as req(agent, object.union(order, {"amount": 900}))
	refunds.allow with input as req(object.union(agent, {"level": "senior"}), object.union(order, {"amount": 900}))
}

test_large_refund_needs_mfa if {
	senior := object.union(agent, {"level": "senior"})
	big := object.union(order, {"amount": 2500})
	refunds.decision.reasons == {"large refund needs MFA"} with input as req(senior, big)
	refunds.allow with input as req(object.union(senior, {"amr": ["pwd", "mfa"]}), big)
}

test_own_order_denied if {
	not refunds.allow with input as req(agent, object.union(order, {"customer_id": "c-9"}))
}

test_not_delivered_denied if {
	not refunds.allow with input as req(agent, object.union(order, {"status": "shipped"}))
}

test_outside_hours_denied if {
	not refunds.allow with input as object.union(req(agent, order), {"now": "2026-10-12T21:30:00Z"})
}

test_missing_attributes_denied if {
	not refunds.allow with input as {"action": "refund", "user": {"groups": ["support"]}}
}
```

النسخة الأولى من السياسة لم يكن فيها القاعدتان `required` و`missing request time`، وهذه نتيجة اختبارها:

```bash
docker run --rm -v "$PWD/policy:/p" -w /p openpolicyagent/opa:1.21.1 test refunds/
```

```text
refunds/refunds_test.rego:43:
data.refunds_test.test_missing_attributes_denied: FAIL (870.331µs)
--------------------------------------------------------------------------------
PASS: 7/8
FAIL: 1/8
```

طلب فيه مجموعة المستخدم فقط، دون منطقة ولا مستوى ولا طلب ولا وقت، حصل على السماح. والصورة التالية تبين السبب:

![لوحتان: على اليسار النسخة الأولى، المدخل فيه groups فقط والطلب مفقود، وكل قواعد الرفض مثل region != order.region وamount > limits[level] وstatus != delivered غير معرفة undefined، فيكون count(deny) == 0 والنتيجة allow = true. وعلى اليمين النسخة المصلحة، قاعدة not key in object.keys تضيف سبب رفض لكل خاصية ناقصة مثل missing order.amount وmissing request time، فيكون count(deny) == 9 والنتيجة allow = false](https://arabroot.io/content/images/2026/10/opa-practice-02-deny-trap.webp)

في Rego القاعدة التي لا تجد قيمة لا تكون false وإنما غير معرفة، لذلك اجعل غياب البيانات سبباً صريحاً للرفض

في Rego إذا قرأت قاعدة قيمة غير موجودة، مثل `input.order.region` والطلب غير موجود، فإن القاعدة كلها تصبح غير معرفة Undefined ولا تضيف شيئاً إلى المجموعة `deny`، فتصبح المجموعة فارغة ويصبح `count(deny) == 0` صحيحاً. وهذا هو الفرق الخطير بين قائمة السماح وقائمة الرفض: في قائمة السماح البيانات الناقصة ترفض، وفي قائمة الرفض تسمح. والحل الذي في النسخة النهائية أن تحول غياب كل خاصية إلى سبب رفض صريح، ولذلك أضفنا أيضاً «لا يوجد حد لهذا المستوى» حتى لا يمر مستوى جديد اسمه `manager` لم يضف إلى ملف الحدود. والآن:

```bash
docker run --rm -v "$PWD/policy:/p" -w /p openpolicyagent/opa:1.21.1 test -v .
docker run --rm -v "$PWD/policy:/p" -w /p openpolicyagent/opa:1.21.1 test --coverage refunds/ | grep '"coverage"' | tail -1
```

```text
data.refunds_test.test_not_delivered_denied: PASS (1.969356ms)
data.gateway_test.test_orders_read_from_anywhere: PASS (2.834525ms)
data.refunds_test.test_outside_hours_denied: PASS (29.166813ms)
data.refunds_test.test_other_region_denied: PASS (31.857827ms)
data.refunds_test.test_missing_attributes_denied: PASS (1.251199ms)
data.refunds_test.test_agent_refunds_small_order: PASS (33.593465ms)
data.refunds_test.test_limit_by_level: PASS (32.116047ms)
data.refunds_test.test_own_order_denied: PASS (29.827066ms)
data.refunds_test.test_large_refund_needs_mfa: PASS (34.642747ms)
data.gateway_test.test_refund_only_from_internal: PASS (36.265077ms)
data.gateway_test.test_admin_closed: PASS (2.400391ms)
PASS: 11/11
  "coverage": 97.77777777777777
```

والاختبارات الأحد عشر تشمل سياسة البوابة التي سوف نراها بعد قليل. وإلى جانب الاختبارات نشغل أداة الفحص [Regal](https://www.openpolicyagent.org/projects/regal?ref=arabroot.io)، وهي التي نبهتنا إلى مسار البيانات الخاطئ:

```bash
docker run --rm -v "$PWD/policy:/p" -w /p ghcr.io/open-policy-agent/regal:0.43.0 lint --format compact .
```

```text
│ envoy/envoy.rego:8:1       │ Rule name shadows built-in                │
│ envoy/envoy.rego:2:9       │ Directory structure should mirror package │
│ envoy/envoy.rego:10:1      │ Messy incremental rule                    │
│ gateway/gateway.rego:14:1  │ Messy incremental rule                    │
│ refunds/refunds.rego:22:15 │ Prefer string interpolation               │
│ system/log.rego:2:9        │ Directory structure should mirror package │
│                            │ Missing entrypoint annotation             │
│ refunds/refunds.rego:43:23 │ Unresolved Reference                      │
│ filters/filters.rego:14:25 │ Unresolved Reference                      │
│ refunds/refunds.rego:30:55 │ Unresolved Reference                      │
│ refunds/refunds.rego:38:72 │ Unresolved Reference                      │
 7 files linted , 11 violations found.
```

هذه آخر سطور المخرج، وهو قبل تعديل أخير على سياسة Envoy أزال التحذير الأول Rule name shadows built-in، فقد كان فيها متغير اسمه `http` وهو اسم مجموعة دوال مدمجة. والتحذيرات المتبقية من نوع Unresolved Reference تبقى لأن Regal لا يقرأ ملف البيانات هنا، وأما الباقي فتحذيرات أسلوب، وفي مشروع حقيقي تقرر في ملف إعداد Regal أيها تتجاهل، واجعل الأداة تعمل في CI مع `opa test`.

## سياسة البوابة، وفخ آخر

البوابة لا تعرف الطلبات ولا المبالغ، فقاعدتها عامة: قراءة الطلبات من أي مكان، والمرتجعات من شبكة المكتب فقط، وكل ما عدا ذلك مرفوض. وهذا الملف `policy/gateway/gateway.rego` مع اختباراته:

```rego
# Coarse rules for APISIX (opa plugin): which paths and methods, from where. No order data here.
package gateway

import rego.v1

default allow := false

# The office network; in the lab it is the Docker network lab-authz-opa-office (10.89.0.0/24).
internal_nets := ["10.0.0.0/8"]

# cidr_contains_matches returns a set, and an empty set is still "true" in Rego, so count it.
from_internal if count(net.cidr_contains_matches(internal_nets, input.var.remote_addr)) > 0

allow if {
	input.request.method == "GET"
	startswith(input.request.path, "/api/orders")
}

allow if {
	input.request.method == "POST"
	startswith(input.request.path, "/api/refunds")
	from_internal
}

# Answer for APISIX: the decision, a status and reason when refused, and a header for the upstream.
result := {"allow": true, "headers": {"X-Policy": "gateway-v1"}} if allow

result := {"allow": false, "status_code": 403, "reason": "blocked by gateway policy"} if not allow
```

```rego
package gateway_test

import rego.v1

import data.gateway

req(method, path, ip) := {"request": {"method": method, "path": path}, "var": {"remote_addr": ip}}

test_orders_read_from_anywhere if gateway.allow with input as req("GET", "/api/orders/7", "203.0.113.9")

test_refund_only_from_internal if {
	gateway.allow with input as req("POST", "/api/refunds", "10.20.0.5")
	not gateway.allow with input as req("POST", "/api/refunds", "203.0.113.9")
}

test_admin_closed if not gateway.allow with input as req("GET", "/api/admin", "10.20.0.5")
```

وفي النسخة الأولى كتبنا `from_internal if net.cidr_contains_matches(internal_nets, input.var.remote_addr)` دون `count`، ففشل اختبار `test_refund_only_from_internal`، لأن الدالة تعيد مجموعة المطابقات، والمجموعة الفارغة قيمة موجودة وليست `false`، فكانت كل العناوين «داخلية». ولو لم يكن هناك اختبار للعنوان الخارجي لما لاحظ أحد ذلك، وهذا سبب أن يكون لكل قاعدة اختبار سماح واختبار رفض. وأما سياسة Envoy `policy/envoy/envoy.rego` فهي القاعدة نفسها بشكل مدخل Envoy:

```rego
# The same coarse rules for Envoy (ext_authz through opa-envoy-plugin); only the input shape differs.
package envoy.authz

import rego.v1

default allow := false

req := input.attributes.request.http

allow if {
	req.method == "GET"
	startswith(req.path, "/api/orders")
}

allow if {
	req.method == "POST"
	startswith(req.path, "/api/refunds")
	count(net.cidr_contains_matches(data.gateway.internal_nets, input.attributes.source.address.socketAddress.address)) > 0
}
```

## الحزمة الموقعة Bundle

في الإنتاج لا تنسخ ملفات السياسة إلى كل سيرفر، وإنما تبنيها في CI كحزمة Bundle واحدة، أي ملف مضغوط فيه السياسات والبيانات، وتضعها على سيرفر ويب أو S3، وتسحبها كل نسخ OPA دورياً كما في [توثيق الحزم](https://www.openpolicyagent.org/docs/management-bundles?ref=arabroot.io). والتوقيع يضمن أن OPA لا تقبل إلا حزمة بنيتها أنت، والصورة التالية تبين الطريق كاملاً:

![مخطط: ملفات policy/*.rego في Git مع المراجعة، ثم CI يشغل opa test وRegal، ثم opa build --signing-key ينتج authz.tar.gz موقعاً، ثم سيرفر الحزم وهو أي سيرفر ويب أو S3، ثم ثلاث نسخ OPA تسحب الحزمة كل 5 إلى 10 ثوان وتتحقق منها بالمفتاح العام: OPA لخدمة المرتجعات، وOPA التي تسألها APISIX، وOPA مع إضافة Envoy، وسجل القرارات يصل إلى جامع مع رقم نسخة الحزمة والبيانات المخفية، وحزمة معدلة ترفض بخطأ digest mismatch](https://arabroot.io/content/images/2026/10/opa-practice-01-distribution.webp)

المفتاح الخاص يبقى في CI، وكل OPA تحمل المفتاح العام فقط، فلا تقبل حزمة لم توقعها أنت

الملف `policy/.manifest` يحدد الجذور Roots التي تملكها الحزمة، أي المسارات في `data` التي لا يكتب فيها غيرها، وسياسة إخفاء البيانات في السجل `policy/system/log.rego` سوف نشرحها في قسم السجلات:

```json
{"roots": ["refunds", "gateway", "envoy", "filters", "system"]}
```

ننشئ زوج مفاتيح RSA ونبني الحزمة الموقعة، دون ملفات الاختبار:

```bash
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
docker run --rm -v "$PWD:/w" -w /w openpolicyagent/opa:1.21.1 build -b policy \
  --revision 2026-10-12.1 --signing-key keys/private.pem --signing-alg RS256 \
  --ignore '*_test.rego' -o bundles/authz.tar.gz
tar -tzf bundles/authz.tar.gz
tar -xzOf bundles/authz.tar.gz /data.json; echo
tar -xzOf bundles/authz.tar.gz /.manifest; echo
```

```text
/data.json
/policy/envoy/envoy.rego
/policy/filters/filters.rego
/policy/gateway/gateway.rego
/policy/refunds/refunds.rego
/policy/system/log.rego
/.signatures.json
/.manifest
{"refunds":{"limits":{"agent":500,"lead":20000,"senior":3000},"mfa_above":1000}}
{"revision":"2026-10-12.1","roots":["refunds","gateway","envoy","filters","system"],"rego_version":1}
```

لاحظ أن ملف البيانات أصبح تحت `refunds` في الحزمة، وهذا ما شرحناه عن مسار `data.refunds.limits`، وأن الملف `.signatures.json` فيه هاش كل ملف موقعاً بالمفتاح الخاص. وإعداد OPA في `opa-config.yaml`:

```yaml
# OPA pulls a signed bundle from the bundle server, reports its status and ships decision logs.
services:
  bundles:
    url: http://lab-authz-opa-bundles
  logs:
    url: http://lab-authz-opa-logs:8000
bundles:
  authz:
    service: bundles
    resource: authz.tar.gz
    polling: { min_delay_seconds: 5, max_delay_seconds: 10 }
    signing:
      keyid: release
decision_logs:
  service: logs
  reporting: { min_delay_seconds: 2, max_delay_seconds: 5 }
status:
  service: logs
keys:
  release:            # the public key only; the private key stays with the CI job that signs
    algorithm: RS256
    key: |
      -----BEGIN PUBLIC KEY-----
      (paste the lines of keys/public.pem here)
      -----END PUBLIC KEY-----
```

في الإعداد أعلاه لاحظ التالي:

- المفتاح العام يكتب في الإعداد بالحقل `key`، وقد حاولنا أولاً أن نعطيه مسار ملف بحقل `key_file` فلم يبدأ OPA ورد بخطأ `invalid keys configuration: no keys provided for key ID release`، ثم حاولنا الخيار `--verification-key` من سطر الأوامر فرد بأنه يعمل مع ملفات الحزم المحلية فقط `enable bundle mode (ie. --bundle) to verify bundle files or directories`.
- OPA تسأل سيرفر الحزم كل خمس إلى عشر ثوان، وترسل سجل القرارات وتقرير الحالة Status إلى الجامع.

وإعداد نسخة Envoy `opa-envoy-config.yaml` يسحب الحزمة نفسها ويفعل إضافة gRPC:

```yaml
# Same signed bundle, plus the Envoy gRPC plugin that answers ext_authz on port 9191.
services:
  bundles:
    url: http://lab-authz-opa-bundles
bundles:
  authz:
    service: bundles
    resource: authz.tar.gz
    polling: { min_delay_seconds: 5, max_delay_seconds: 10 }
    signing: { keyid: release }
decision_logs:
  console: true      # decision logs to stdout (docker logs)
plugins:
  envoy_ext_authz_grpc:
    addr: :9191
    path: envoy/authz/allow
keys:
  release:            # the public key only; the private key stays with the CI job that signs
    algorithm: RS256
    key: |
      -----BEGIN PUBLIC KEY-----
      (paste the lines of keys/public.pem here)
      -----END PUBLIC KEY-----
```

والجامع `collector.py` برنامج صغير يطبع ما يصله:

```python
# Receives OPA decision logs and status reports (gzip JSON) and prints one short line per entry.
import gzip, json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class H(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        raw = self.rfile.read(int(self.headers["Content-Length"]))
        if self.headers.get("Content-Encoding") == "gzip":
            raw = gzip.decompress(raw)
        body = json.loads(raw)
        if self.path == "/logs":
            for e in body:
                print(json.dumps({k: e.get(k) for k in ("timestamp", "path", "input", "result", "erased",
                                                        "bundles", "metrics")}, ensure_ascii=False), flush=True)
        else:
            print("status", json.dumps({"bundles": body.get("bundles")}), flush=True)
        self.send_response(200)
        self.send_header("Content-Length", "0")
        self.end_headers()

ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever()
```

ثم ملف `compose.yaml` كاملاً، وبقية الملفات الصغيرة:

```yaml
# OPA deep dive: bundle server, OPA with decision logs, APISIX and Envoy in front of one app, and PostgreSQL.
name: lab-authz-opa
services:
  bundles:
    image: nginx:1.31.6-alpine
    container_name: lab-authz-opa-bundles
    volumes: ["./bundles:/usr/share/nginx/html:ro"]
  logs:
    image: python:3.13.16-slim
    container_name: lab-authz-opa-logs
    command: python -u /app/collector.py
    volumes: ["./collector.py:/app/collector.py:ro"]
  opa:
    image: openpolicyagent/opa:1.21.1
    container_name: lab-authz-opa
    command: ["run", "--server", "--addr=0.0.0.0:8181", "--config-file=/config.yaml", "--log-level=error"]
    volumes: ["./opa-config.yaml:/config.yaml:ro"]
    ports: ["127.0.0.1:18181:8181"]
    depends_on: [bundles, logs]
  opa-envoy:
    image: openpolicyagent/opa:1.21.1-envoy
    container_name: lab-authz-opa-envoy
    command: ["run", "--server", "--addr=0.0.0.0:8181", "--config-file=/config.yaml", "--log-level=error"]
    volumes: ["./opa-envoy-config.yaml:/config.yaml:ro"]
    depends_on: [bundles]
  echo:
    image: nginx:1.31.6-alpine
    container_name: lab-authz-opa-echo
    volumes: ["./echo.conf:/etc/nginx/conf.d/default.conf:ro"]
  apisix:
    image: apache/apisix:3.19.0-debian
    container_name: lab-authz-opa-apisix
    volumes:
      - ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
      - ./apisix/apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro
    networks: [default, office]
  envoy:
    image: envoyproxy/envoy:v1.39.3
    container_name: lab-authz-opa-envoyproxy
    volumes: ["./envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro"]
    networks: [default, office]
  pg:
    image: postgres:18.6
    container_name: lab-authz-opa-pg
    environment: { POSTGRES_USER: shop, POSTGRES_PASSWORD: lab-authz-opa-pg-5e1a, POSTGRES_DB: shop }
  client:
    image: python:3.13.16-slim
    container_name: lab-authz-opa-client
    command: sleep infinity
    volumes: ["./:/lab:ro"]
    networks: [office]     # the client sits on the "office" network
  outside:
    image: python:3.13.16-slim
    container_name: lab-authz-opa-outside
    command: sleep infinity
    volumes: ["./:/lab:ro"]
    networks: [default]    # and this one stands for the internet
networks:
  default:
    name: lab-authz-opa-net
  office:
    name: lab-authz-opa-office
    ipam: { config: [{ subnet: 10.89.0.0/24 }] }
```

`apisix/config.yaml` و`apisix/apisix.yaml`: البوابة مع إضافة opa 

```yaml
# APISIX 3.19 in standalone mode with the opa plugin.
apisix:
  node_listen: 9080
  dns_resolver_valid: 5
deployment:
  role: data_plane
  role_data_plane:
    config_provider: yaml
plugins:
  - opa
  - proxy-rewrite
```

```yaml
upstreams:
  - id: orders-app
    nodes: { "lab-authz-opa-echo:8080": 1 }
routes:
  - id: api
    uri: /api/*
    upstream_id: orders-app
    plugins:
      opa:
        host: http://lab-authz-opa:8181
        policy: gateway/result          # asks POST /v1/data/gateway/result
        timeout: 500                     # ms; a slow PDP must not hold every request
        send_headers_upstream: ["X-Policy"]
  - id: open                           # the same app without the opa plugin, to measure its cost
    uri: /open/*
    upstream_id: orders-app
#END
```

`envoy/envoy.yaml`: Envoy مع مرشح ext\_authz 

```yaml
# Envoy 1.39 asks OPA (opa-envoy-plugin, gRPC) before every request: the ext_authz filter.
static_resources:
  listeners:
    - name: main
      address: { socket_address: { address: 0.0.0.0, port_value: 8000 } }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress
                route_config:
                  virtual_hosts:
                    - name: app
                      domains: ["*"]
                      routes: [{ match: { prefix: "/" }, route: { cluster: orders-app } }]
                http_filters:
                  - name: envoy.filters.http.ext_authz
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
                      transport_api_version: V3
                      failure_mode_allow: false          # PDP down means refuse (fail closed)
                      grpc_service:
                        envoy_grpc: { cluster_name: opa }
                        timeout: 0.5s
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
    - name: orders-app
      type: STRICT_DNS
      load_assignment:
        cluster_name: orders-app
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: lab-authz-opa-echo, port_value: 8080 } } } }] }]
    - name: opa
      type: STRICT_DNS
      typed_extension_protocol_options:
        envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
          "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
          explicit_http_config: { http2_protocol_options: {} }
      load_assignment:
        cluster_name: opa
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: lab-authz-opa-envoy, port_value: 9191 } } } }] }]
```

`echo.conf` و`try.py` و`bench.py` و`input-ok.json`: التطبيق وأدوات الطلب والقياس 

```nginx
server {
  listen 8080;
  default_type application/json;
  location / { return 200 '{"upstream": "orders-app", "path": "$uri", "x_policy": "$http_x_policy"}\n'; }
}
```

```python
# Sends the same three requests through APISIX and through Envoy and prints the status codes.
import sys, urllib.error, urllib.request

for name, base in (("apisix", "http://lab-authz-opa-apisix:9080"), ("envoy", "http://lab-authz-opa-envoyproxy:8000")):
    for method, path in (("GET", "/api/orders/7"), ("POST", "/api/refunds"), ("GET", "/api/admin")):
        try:
            r = urllib.request.urlopen(urllib.request.Request(base + path, method=method, data=b"{}" if method == "POST" else None))
            print(f"{name:6} {method:4} {path:14} -> {r.status} {r.read().decode().strip()}")
        except urllib.error.HTTPError as e:
            print(f"{name:6} {method:4} {path:14} -> {e.code} {e.read().decode().strip()[:60]}")
```

```python
# Latency of one decision, from the office client, over kept-alive connections.
#   python bench.py opa|apisix|apisix-open|envoy [N]
import http.client, json, sys, time

mode, n = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 2000
body, headers, method = None, {}, "GET"
if mode == "opa":
    host, port, path, method = "lab-authz-opa", 8181, "/v1/data/refunds/decision", "POST"
    body, headers = open("/lab/input-ok.json").read(), {"Content-Type": "application/json"}
elif mode == "envoy":
    host, port, path = "lab-authz-opa-envoyproxy", 8000, "/api/orders/7"
else:
    host, port = "lab-authz-opa-apisix", 9080
    path = "/open/orders/7" if mode == "apisix-open" else "/api/orders/7"
conn = http.client.HTTPConnection(host, port, timeout=5)
times, codes = [], {}
for i in range(n + 100):
    t0 = time.perf_counter()
    conn.request(method, path, body=body, headers=headers)
    r = conn.getresponse(); r.read()
    if i >= 100:
        times.append((time.perf_counter() - t0) * 1000); codes[r.status] = codes.get(r.status, 0) + 1
times.sort()
q = lambda p: times[min(len(times) - 1, int(p * len(times)))]
print(f"{mode:12} n={n} status={codes} p50={q(.5):.2f}ms p95={q(.95):.2f}ms p99={q(.99):.2f}ms")
```

```json
{"input": {
  "action": "refund", "now": "2026-10-12T10:15:00Z",
  "user":  {"groups": ["support"], "region": "riyadh", "level": "senior", "customer_id": "c-9", "amr": ["pwd"]},
  "order": {"id": "o-1042", "region": "riyadh", "status": "delivered", "amount": 2400, "customer_id": "c-1"}
}}
```

بعد أن تضع سطور المفتاح العام في الملفين، نشغل كل شيء ونتأكد أن الحزمة فعلت:

```bash
docker compose up -d
sleep 15; docker logs lab-authz-opa-logs 2>&1 | grep status | tail -1
```

```text
status {"bundles": {"authz": {"name": "authz", "active_revision": "2026-10-12.1", "last_successful_activation": "2026-10-12T15:08:11.605413961Z", "type": "snapshot", "size": 3155, "last_successful_download": "2026-10-12T15:08:11.595791202Z", ...
```

وعندما عدلنا سياسة البوابة لاحقاً بنينا الحزمة برقم `2026-10-12.2` ثم `2026-10-12.3` ووضعناها مكان القديمة، فظهر الرقم الجديد في تقرير الحالة خلال عشر ثوان دون إعادة تشغيل أي شيء، وهذا هو النشر في الإنتاج: طلب دمج، ثم CI يبني ويوقع ويرفع، ثم تسحب كل النسخ.

### نكسرها: حزمة معدلة في الطريق

لنفرض أن شخصاً وصل إلى سيرفر الحزم فعدل سياسة البوابة لتسمح بكل شيء، وأعاد ضغط الحزمة مع ملف التوقيع القديم:

```bash
mkdir /tmp/evil && tar -xzf bundles/authz.tar.gz -C /tmp/evil
sed -i 's/^default allow := false/default allow := true/' /tmp/evil/policy/gateway/gateway.rego
cp bundles/authz.tar.gz good.tar.gz
tar -czf bundles/authz.tar.gz -C /tmp/evil .manifest .signatures.json data.json policy
sleep 14; docker logs lab-authz-opa-logs 2>&1 | grep status | tail -1
```

ومن تقرير الحالة الحقول المهمة:

```text
'active_revision': '2026-10-12.2', 'code': 'bundle_error', 'message': 'policy/gateway/gateway.rego: digest mismatch (want: edcb224281f02504491db16cd94f1ed229f54e23856a0be761d74fc6b895638c, got: 70d48104df6c9871a3fa9f83b6c0363d09463b0ea3a8f17c8f268360619d82a6)'
```

رفضت OPA الحزمة لأن هاش الملف لا يطابق التوقيع، وبقيت تعمل بالنسخة السابقة `2026-10-12.2`، وبقيت البوابة ترفض المرتجعات من الخارج كما سوف نرى. وتقرير الحالة فيه الخطأ نفسه، لذلك اجعل المراقبة تنبه عندما يظهر `bundle_error` أو عندما لا يتغير `active_revision` بعد نشر جديد، فهذا يعني أن نسخة ما تعمل بسياسة قديمة. ثم نعيد الحزمة الصحيحة:

```bash
cp good.tar.gz bundles/authz.tar.gz && rm -rf /tmp/evil good.tar.gz
```

## سجل القرارات، دون البيانات الشخصية

الخدمة تسأل OPA عن قرار المرتجعات بطلب REST كما في المقال الأول، مع المستخدم والطلب والوقت:

```bash
curl -s -X POST 127.0.0.1:18181/v1/data/refunds/decision -H 'Content-Type: application/json' -d @input-ok.json; echo
sed 's/"amr": \["pwd"\]/"amr": ["pwd", "mfa"]/' input-ok.json | curl -s -X POST 127.0.0.1:18181/v1/data/refunds/decision -H 'Content-Type: application/json' -d @-; echo
curl -s -X POST 127.0.0.1:18181/v1/data/refunds/decision -H 'Content-Type: application/json' -d '{"input": {"action": "refund", "user": {"groups": ["support"]}}}'; echo
```

```text
{"decision_id":"58b5e284-2b3a-443c-88d6-e9d07e76a871","result":{"allow":false,"reasons":["large refund needs MFA"]}}
{"decision_id":"5b9bf866-5954-4d82-b608-6ef320b40163","result":{"allow":true,"reasons":[]}}
{"decision_id":"12e442f1-fa68-4c3f-b9b3-8c1bda422d05","result":{"allow":false,"reasons":["missing order.amount","missing order.customer_id","missing order.region","missing order.status","missing request time","missing user.amr","missing user.customer_id","missing user.level","missing user.region"]}}
```

موظف بمستوى senior يريد رد 2,400 ريال، والحد 3,000، ولكن المبلغ فوق الألف فيحتاج MFA، وعندما أضفنا `mfa` إلى طرق المصادقة سمح له. وهذا ما وصل إلى الجامع عن الطلب الأول:

```json
{"timestamp": "2026-10-12T15:08:36.23164294Z", "path": "refunds/decision", "input": {"action": "refund", "now": "2026-10-12T10:15:00Z", "order": {"amount": 2400, "id": "o-1042", "region": "riyadh", "status": "delivered"}, "user": {"amr": ["pwd"], "groups": ["support"], "level": "senior", "region": "riyadh"}}, "result": {"allow": false, "reasons": ["large refund needs MFA"]}, "erased": ["/input/order/customer_id", "/input/user/customer_id"], "bundles": {"authz": {"revision": "2026-10-12.1"}}, "metrics": {"counter_server_query_cache_hit": 0, "timer_rego_external_resolve_ns": 1600, "timer_rego_input_parse_ns": 531650, "timer_rego_query_compile_ns": 169116, "timer_rego_query_eval_ns": 884083, "timer_server_handler_ns": 2630347}}
```

في السجل أعلاه لاحظ التالي:

- رقم العميل حذف من المدخل، والحقل `erased` يقول ما الذي حذف، وهذا عمل القاعدة `mask` في الحزمة `system.log` كما في [توثيق سجل القرارات](https://www.openpolicyagent.org/docs/management-decision-logs?ref=arabroot.io)، فالسجل يقول ماذا قرر المحرك ولماذا، دون أن يحمل بيانات لا يحتاجها المدقق.
- كل قرار يحمل رقم نسخة الحزمة التي اتخذته، فإذا سألك أحد لماذا رفض طلب يوم الأحد تعرف بالضبط أي نسخة من السياسة كانت تعمل.
- القرار الأول أخذ 2.6 ملي ثانية في المحرك لأن الاستعلام ترجم لأول مرة، والثاني أخذ 0.42 لأن `counter_server_query_cache_hit` أصبح 1، أي أن الاستعلام المترجم حفظ، وهذا ليس Cache للقرار، فكل مدخل جديد يقيم من جديد.

```rego
# Decision log masking: the customer number is personal data and never leaves OPA in the logs.
package system.log

import rego.v1

mask contains "/input/user/customer_id"

mask contains "/input/order/customer_id"
```

## OPA في البوابات: APISIX وEnvoy

في APISIX الإضافة `opa` ترسل إلى OPA وصف الطلب، المسار والطريقة والترويسات وعنوان المرسل في `var.remote_addr`، وتنتظر رداً فيه `allow` ويمكن أن يحمل رمز الحالة والسبب وترويسات تمرر إلى التطبيق، وهذا شكل القاعدة `result` في سياسة البوابة. وفي Envoy المرشح ext\_authz يسأل خدمة خارجية عبر gRPC قبل كل طلب، والإضافة opa-envoy-plugin تجعل OPA هي هذه الخدمة كما في [توثيق OPA مع Envoy](https://www.openpolicyagent.org/docs/envoy?ref=arabroot.io)، والخيار `failure_mode_allow: false` في إعداد Envoy يعني أن تعطل OPA يرفض الطلب، وهو الافتراضي كما في [توثيق المرشح](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext%5Fauthz/v3/ext%5Fauthz.proto?ref=arabroot.io) ولكننا كتبناه صراحة. والصورة التالية تبين أين تقع البوابتان بالنسبة للخدمة، مع الأزمنة التي قسناها:

![مخطط: موظف الدعم agent يرسل الطلب إلى APISIX مع إضافة opa، وزمنها p50 1.65 ملي ثانية مقابل 0.60 دونها، أو إلى Envoy مع ext_authz وزمنها p50 2.08 ملي ثانية، والبوابتان تسألان OPA عن data.gateway بقاعدة POST /api/refunds من 10.0.0.0/8 فقط وGET /api/orders/*، ثم يصل الطلب إلى خدمة المرتجعات refunds service التي تقرأ الطلب من قاعدة بياناتها، أي المنطقة والمبلغ والحالة، وتسأل OPA عن data.refunds بالمستخدم والطلب والوقت بزمن p50 0.58 ملي ثانية](https://arabroot.io/content/images/2026/10/opa-practice-03-gateway-service.webp)

الأزمنة من مختبرنا على جهاز واحد عبر اتصال مفتوح Keep-alive، وهي للمقارنة بين الطبقات فقط

نرسل الطلبات الثلاثة نفسها عبر البوابتين، مرة من العميل في شبكة المكتب ومرة من العميل الخارجي:

```bash
for c in client outside; do echo "--- $c"; docker exec lab-authz-opa-$c python /lab/try.py; done
```

```text
--- client
apisix GET  /api/orders/7  -> 200 {"upstream": "orders-app", "path": "/api/orders/7", "x_policy": "gateway-v1"}
apisix POST /api/refunds   -> 200 {"upstream": "orders-app", "path": "/api/refunds", "x_policy": "gateway-v1"}
apisix GET  /api/admin     -> 403 blocked by gateway policy
envoy  GET  /api/orders/7  -> 200 {"upstream": "orders-app", "path": "/api/orders/7", "x_policy": ""}
envoy  POST /api/refunds   -> 200 {"upstream": "orders-app", "path": "/api/refunds", "x_policy": ""}
envoy  GET  /api/admin     -> 403
--- outside
apisix GET  /api/orders/7  -> 200 {"upstream": "orders-app", "path": "/api/orders/7", "x_policy": "gateway-v1"}
apisix POST /api/refunds   -> 403 blocked by gateway policy
apisix GET  /api/admin     -> 403 blocked by gateway policy
envoy  GET  /api/orders/7  -> 200 {"upstream": "orders-app", "path": "/api/orders/7", "x_policy": ""}
envoy  POST /api/refunds   -> 403
envoy  GET  /api/admin     -> 403
```

في المخرج أعلاه لاحظ أن APISIX مررت الترويسة `X-Policy` التي أرسلتها OPA إلى التطبيق، وأعادت سبب الرفض من السياسة، أما Envoy في إعدادنا فرفض دون نص، لأن القاعدة `envoy.authz.allow` تعيد قيمة منطقية فقط، والإضافة تقبل أيضاً كائناً فيه ترويسات ورمز حالة إذا احتجت ذلك.

### نكسرها: عنوان المرسل ليس كما تظن

في الإعداد الأول نشرنا منفذي البوابتين على 127.0.0.1 وأرسلنا الطلب من الجهاز نفسه، فرفضت APISIX طلب المرتجعات، أما Envoy فقبله. ولما فعلنا سجل القرارات في نسخة Envoy رأينا السبب:

```text
{'socketAddress': {'address': '10.89.0.1', 'portValue': 47850}} POST True
```

الطلب من خارج Docker وصل إلى Envoy من بوابة شبكة المكتب `10.89.0.1`، لأن Docker اختار تلك الشبكة لتمرير المنفذ المنشور، فأصبح طلب من خارج المكتب «داخلياً» في نظر السياسة. وهذا يحدث في الإنتاج أيضاً خلف موزع حمل أو NAT، حيث يرى المحرك عنوان آخر جهاز في الطريق لا عنوان المستخدم، لذلك لا تبن قاعدة على عنوان المرسل إلا إذا كنت تعرف كل جهاز في الطريق، ولا تثق بترويسة `X-Forwarded-For` إلا من موزع حمل تعرفه، وقد شرحنا ذلك في [Reverse Proxy أم Load Balancer أم API Gateway](https://arabroot.io/articles/reverse-proxy-%D8%A7%D9%85-load-balancer-%D8%A7%D9%85-api-gateway/). وفي التجربة أزلنا المنافذ المنشورة ووضعنا عميلين في شبكتين مختلفتين، فأصبحت النتيجة كما في المخرج السابق.

### نكسرها: نوقف OPA

```bash
docker stop lab-authz-opa lab-authz-opa-envoy
docker exec lab-authz-opa-client python /lab/try.py
docker logs lab-authz-opa-apisix 2>&1 | grep -i opa | tail -2
docker start lab-authz-opa lab-authz-opa-envoy
```

```text
apisix GET  /api/orders/7  -> 403 <html>
apisix POST /api/refunds   -> 403 <html>
apisix GET  /api/admin     -> 403 <html>
envoy  GET  /api/orders/7  -> 403
envoy  POST /api/refunds   -> 403
envoy  GET  /api/admin     -> 403
[error] opa.lua:102: phase_func(): failed to process OPA decision, err: failed to parse domain: failed to query the DNS server: dns server error: 2 server failure, client: 10.89.0.3, ...
[warn] plugin.lua:1409: run_plugin(): opa exits with http status code 403, client: 10.89.0.3, ...
```

البوابتان رفضتا كل شيء، حتى قراءة الطلبات المسموحة للجميع، وهذا هو الرفض عند التعطل Fail Closed، وتوثيق إضافة APISIX لا يذكر هذا السلوك صراحة، لذلك اختبره بنفسك قبل أن تعتمد عليه. ولأن تعطل OPA يعني تعطل البوابة كلها، شغل OPA بجانب كل نسخة من البوابة أو كمجموعة نسخ، فهي لا تحفظ حالة غير الحزمة التي تسحبها، واجعل المهلة قصيرة كما في الخيار `timeout: 500` في إعداد APISIX.

## الأداء

قسنا ثلاث طبقات: تقييم السياسة داخل OPA بالأمر `opa bench`، والطلب إلى OPA عبر REST، والطلب كاملاً عبر كل بوابة:

```bash
python3 -c "import json;print(json.dumps(json.load(open('input-ok.json'))['input']))" > bench-input.json
docker run --rm -v "$PWD:/w" -w /w openpolicyagent/opa:1.21.1 bench -d policy --ignore '*_test.rego' -i bench-input.json 'data.refunds.decision' | head -5
docker exec lab-authz-opa-outside python /lab/bench.py opa 3000
for m in apisix-open apisix envoy; do docker exec lab-authz-opa-client python /lab/bench.py $m 3000; done
```

```text
│ samples                                         │   8917 │
│ ns/op                                           │ 113010 │
│ B/op                                            │  24975 │
│ allocs/op                                       │    557 │
opa          n=3000 status={200: 3000} p50=0.58ms p95=1.17ms p99=1.69ms
apisix-open  n=3000 status={200: 3000} p50=0.60ms p95=1.33ms p99=1.99ms
apisix       n=3000 status={200: 3000} p50=1.65ms p95=2.92ms p99=4.68ms
envoy        n=3000 status={200: 3000} p50=2.08ms p95=3.44ms p99=4.66ms
```

في المخرج أعلاه لاحظ التالي:

- تقييم سياسة المرتجعات يأخذ نحو 113 ميكروثانية، والطلب كاملاً عبر REST 0.58 ملي ثانية في الوسيط، فمعظم الزمن في الشبكة وقراءة JSON لا في السياسة.
- إضافة opa في APISIX أضافت نحو ملي ثانية واحدة في الوسيط مقارنة بالمسار نفسه دونها (`/open/*`)، وEnvoy مع ext\_authz عبر gRPC 2.08 ملي ثانية للطلب كاملاً.
- هذه أرقام من جهاز واحد عليه تجارب أخرى، وكلها لطلب صغير، والسياسة التي تمر على قوائم كبيرة في `data` أبطأ، لذلك اقرأ [توثيق الأداء](https://www.openpolicyagent.org/docs/policy-performance?ref=arabroot.io) وقس سياستك أنت بالأمر `opa bench` في CI حتى تعرف إذا أبطأها تعديل.

## ترشيح القوائم: من السياسة إلى شرط SQL

شاشة «طلبات المرتجعات» تحتاج كل الطلبات التي يستطيع الموظف ردها، وOPA لا تحفظ الطلبات، فلا يوجد عندها ListObjects كما في [OpenFGA](https://arabroot.io/articles/openfga-%D8%B9%D9%85%D9%84%D9%8A%D8%A7-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D8%A7%D9%84%D8%B9%D9%84%D8%A7%D9%82%D8%A7%D8%AA-%D9%85%D9%86-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%A7%D9%86%D8%AA%D8%A7%D8%AC/#queries). والحل أن نعطي OPA ما نعرفه، أي المستخدم، ونقول لها إن الطلب نفسه غير معروف Unknown، فتقيم السياسة جزئياً Partial Evaluation وتعيد الشرط الذي يجب أن يحققه الطلب، مترجماً إلى SQL، كما في [درس الترشيح بلغة SQL](https://www.openpolicyagent.org/docs/filtering/tutorial-sql-filtering?ref=arabroot.io). وهذه سياسة `policy/filters/filters.rego`، والتعليق في أعلاها يحدد أن `input.orders` غير معروف:

```rego
# METADATA
# description: Which orders an agent may see in the refund queue, compiled to SQL by OPA's Compile API.
# scope: package
# compile:
#   unknowns: [input.orders]
package filters

import rego.v1

include if {
	"support" in input.user.groups
	input.orders.region == input.user.region
	input.orders.status == "delivered"
	input.orders.amount <= data.refunds.limits[input.user.level]
}
```

ننشئ جدول الطلبات من الملف `orders.sql`، ثم نطلب الشرط لموظف بمستوى agent وآخر بمستوى senior وثالث ليس من الدعم:

```sql
CREATE TABLE orders (id text PRIMARY KEY, region text, status text, amount numeric, customer_id text);
INSERT INTO orders VALUES
  ('o-1041', 'riyadh', 'delivered', 120,  'c-1'),
  ('o-1042', 'riyadh', 'delivered', 2400, 'c-1'),
  ('o-1043', 'riyadh', 'shipped',   80,   'c-2'),
  ('o-1044', 'jeddah', 'delivered', 300,  'c-3'),
  ('o-1045', 'riyadh', 'delivered', 450,  'c-4');
```

```bash
docker exec -i lab-authz-opa-pg psql -q -U shop -d shop < orders.sql
for lvl in agent senior; do curl -s -X POST 127.0.0.1:18181/v1/compile/filters/include \
  -H 'Content-Type: application/json' -H 'Accept: application/vnd.opa.sql.postgresql+json' \
  -d "{\"input\": {\"user\": {\"groups\": [\"support\"], \"region\": \"riyadh\", \"level\": \"$lvl\"}}}"; echo; done
curl -s -X POST 127.0.0.1:18181/v1/compile/filters/include \
  -H 'Content-Type: application/json' -H 'Accept: application/vnd.opa.sql.postgresql+json' \
  -d '{"input": {"user": {"groups": ["sales"], "region": "riyadh", "level": "agent"}}}'; echo
```

```text
{"result":{"query":"WHERE (orders.region = E'riyadh' AND orders.status = E'delivered' AND orders.amount <= E'500')"}}
{"result":{"query":"WHERE (orders.region = E'riyadh' AND orders.status = E'delivered' AND orders.amount <= E'3000')"}}
{}
```

```bash
docker exec lab-authz-opa-pg psql -U shop -d shop -c "SELECT id, amount FROM orders WHERE (orders.region = E'riyadh' AND orders.status = E'delivered' AND orders.amount <= E'500') ORDER BY id"
```

```text
   id   | amount
--------+--------
 o-1041 |    120
 o-1045 |    450
(2 rows)
```

في المخرج أعلاه لاحظ التالي:

- OPA حسبت حد المستوى من البيانات، 500 أو 3000، ووضعته في الشرط، فالخدمة لا تعرف شيئاً عن الحدود.
- الرد الفارغ `{}` لموظف المبيعات يعني أنه لا يوجد أي شرط يجعله يرى شيئاً، فلا تقم بتشغيل الاستعلام دون شرط في هذه الحالة، وإنما أعد قائمة فارغة، وهذا ما يذكره الدرس الرسمي صراحة.
- ليست كل سياسة قابلة للترجمة إلى SQL، فالتوثيق يحدد ما يمكن أن يظهر في السياسة التي تترجم، لذلك نكتب سياسة الترشيح منفصلة وبسيطة، ونبقي سياسة القرار على الطلب الواحد كاملة، فالقائمة تعرض والقرار يفحص عند الرد الفعلي.

### إزالة التجربة

```bash
docker compose down -v
rm -rf ~/opa-lab
```

والأمر يحذف Containers المشروع وشبكتيه فقط، فلا يوجد في هذه التجربة Volume مسمى، ولا تستخدم أوامر التنظيف العامة لأنها تحذف أشياء لمشاريع أخرى.

## الأخطاء الشائعة

- قائمة رفض دون فحص البيانات الناقصة: كل قاعدة غير معرفة تختفي، والطلب الفارغ يمر.
- استخدام نتيجة دالة تعيد مجموعة كشرط مباشرة، مثل `net.cidr_contains_matches`، فالمجموعة الفارغة ليست false.
- مسارات بيانات تعمل في الاختبار ولا تعمل في الحزمة، لأن المجلد يصبح جزءاً من المسار.
- حزم غير موقعة، أو المفتاح الخاص موجود على السيرفرات التي تشغل OPA.
- لا أحد يراقب `bundle_error` و`active_revision`، فتبقى نسخة على سياسة قديمة أسابيع.
- سجل قرارات يحمل أرقام العملاء والتوكنات كاملة، بدل إخفائها بقاعدة `mask`.
- قواعد على عنوان المرسل دون معرفة ما يمر به الطلب من NAT وموزعات حمل.
- جعل البوابة تقرر على البيانات، مثل المبلغ، وهي لا تعرفها، بدل أن تقرر الخدمة.
- تشغيل استعلام القائمة دون شرط عندما يعيد Compile API ردا فارغاً.

## الخلاصة

- اخترنا OPA لأن المحرك نفسه يعمل في APISIX عبر إضافة مدمجة وفي Envoy عبر إضافة مفتوحة المصدر وفي الخدمات، أما Cerbos فهو الأنسب لسياسات الخدمات وحدها بملفات YAML يقرؤها غير المبرمجين، وتكامل Envoy فيه عبر Synapse التجاري.
- سياسة ABAC كقائمة أسباب للرفض تعطي سبباً لكل رفض، ولكنها تسمح بالطلب الناقص إذا لم تجعل غياب كل خاصية سبباً للرفض، وهذا ما كشفه اختبار واحد في تجربتنا، وكشف اختبار آخر أن المجموعة الفارغة ليست false.
- الحزمة الموقعة تبنى في CI وتسحبها كل النسخ، والحزمة المعدلة رفضت بخطأ digest mismatch مع بقاء النسخة السابقة، وتقرير الحالة يخبرك بذلك.
- سجل القرارات يحمل رقم نسخة السياسة ويخفي البيانات الشخصية بقاعدة `mask`.
- البوابتان رفضتا كل شيء عندما توقفت OPA، وأضافت إضافة APISIX نحو ملي ثانية واحدة، وتقييم السياسة نفسه نحو 113 ميكروثانية.
- Compile API يحول السياسة إلى شرط SQL منذ OPA 1.9.0، فتعرض القائمة ما يسمح به المحرك دون أن تحمل الخدمة قواعده.

[![](https://arabroot.io/content/images/2026/10/authorization-cover-cover.webp)دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-authorization-rbac-%D9%88-rebac/)[![](https://arabroot.io/content/images/2026/10/authz-platform-cover-cover.webp)دليلمنصة صلاحيات مركزية لخدماتك Microservicesكيف تجعل كل خدماتك تسأل نقطة قرار واحدة، وتبقي علاقاتها متزامنة مع بياناتك، وماذا يحدث عندما يتأخر سحب الصلاحية.](https://arabroot.io/articles/%D9%85%D9%86%D8%B5%D8%A9-%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D9%85%D8%B1%D9%83%D8%B2%D9%8A%D8%A9-%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%D9%83-microservices/)[![](https://arabroot.io/content/images/2026/10/openfga-practice-cover-cover.webp)دليلOpenFGA عملياً: نموذج العلاقات من التصميم إلى الإنتاجكيف تصمم نموذج علاقات في OpenFGA للمؤسسة والفرق والمجلدات، وتختبره وتغيره وتشغله على PostgreSQL دون مفاجآت.](https://arabroot.io/articles/openfga-%D8%B9%D9%85%D9%84%D9%8A%D8%A7-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D8%A7%D9%84%D8%B9%D9%84%D8%A7%D9%82%D8%A7%D8%AA-%D9%85%D9%86-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%A7%D9%86%D8%AA%D8%A7%D8%AC/)[![](https://arabroot.io/content/images/2026/10/apisix-practice-cover-cover.webp)دليلApache APISIX عملياً: من أول Route إلى بوابة إنتاجكيف تشغل Apache APISIX مع etcd، وتحمي واجهاتك بالتوكنات والحدود وauthentik، وتراقبها وتأخذ نسخة احتياطية من إعدادها.](https://arabroot.io/articles/apache-apisix-%D8%B9%D9%85%D9%84%D9%8A%D8%A7-%D9%85%D9%86-%D8%A7%D9%88%D9%84-route-%D8%A7%D9%84%D9%89-%D8%A8%D9%88%D8%A7%D8%A8%D8%A9-%D8%A7%D9%86%D8%AA%D8%A7%D8%AC/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على OPA 1.21.1 وopa-envoy-plugin 1.21.1 وEnvoy 1.39.3 وAPISIX 3.19.0 وRegal 0.43.0 وPostgreSQL 18.6 على Docker Engine 29.8.0، ومراجعة تراخيص OPA وCerbos وحالة مشروع OPA بعد أغسطس 2025.