في منصة صلاحيات مركزية لخدماتك استخدمنا محرك علاقات لأن المشاركة والمجلدات علاقات، ولكن كثيراً من القواعد في الخدمات ليست علاقات وإنما خصائص: موظف الدعم يرد المبلغ إذا كان الطلب في منطقته، وكان المبلغ تحت حده، وكان الطلب قد سلم، ولم يكن الطلب طلبه هو، وكان ذلك في ساعات العمل. وهذه القاعدة هي ما يسمى الصلاحيات حسب الخصائص ABAC التي عرفناها في المقال الأول، ومكانها محرك سياسات، وفي هذا المقال نأخذ OPA من أول سياسة إلى الإنتاج: الاختبارات، والتوزيع الموقع، وسجل القرارات، والبوابات، والأداء.
المواضيع في السلسلة:
- الصلاحيات المركزية Authorization: النماذج والمصطلحات والمحركات.
- منصة صلاحيات مركزية لخدماتك: المنصة والمزامنة والاتساق.
- OpenFGA عملياً: نموذج العلاقات.
- OPA عملياً (الموضوع الحالي).
وقد كتبنا في المقال الأول سياسة صغيرة بلغة Rego وشغلنا OPA كسيرفر ورأينا سجل القرارات على الشاشة، فلن نعيد ذلك هنا، وسوف نناقش في هذا المقال ما يلي:
- لماذا اخترنا OPA لهذا المقال وليس Cerbos، ومتى يكون Cerbos هو الأنسب.
- سياسة ABAC حقيقية للمرتجعات مع اختباراتها، وفخ قائمة الرفض الذي جعل طلباً ناقص البيانات يمر في أول نسخة كتبناها.
- بناء الحزمة Bundle وتوقيعها وتوزيعها على عدة نسخ من OPA، وما يحدث عندما يعدل أحد الحزمة في الطريق.
- سجل القرارات مع إخفاء البيانات الشخصية، وربط OPA ببوابة APISIX وبEnvoy، وما يحدث عندما يتوقف المحرك.
- قياس الأداء، وتحويل السياسة إلى شرط SQL لترشيح القوائم.
OPA أم Cerbos؟
المحركان بترخيص Apache-2.0، وكلاهما يناسب ABAC، وقد عرفناهما في مقارنة المحركات. والسؤال الذي حسم الاختيار لهذا المقال هو: هل تريد المحرك نفسه في الخدمات وفي البوابات، بأدوات مفتوحة المصدر فقط؟
| المعيار | 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 ومستودع opa-envoy-plugin وواجهات Cerbos وصفحة Synapse التي تذكر دعم Envoy ومفاتيح الترخيص، وقد راجعناها في أكتوبر 2026. لذلك اخترنا OPA، لأن المنصة في هذه السلسلة فيها بوابة APISIX، ونريد القواعد العامة في البوابة والقواعد الدقيقة في الخدمة بلغة واحدة. أما إذا كانت سياساتك داخل الخدمات فقط، ويكتبها أو يراجعها أشخاص من خارج فريق التطوير، فإن Cerbos بملفات YAML وأدواره المشتقة Derived Roles أسهل في القراءة، وهو الأنسب في هذه الحالة.
ويجدر الإشارة هنا إلى تغيير مهم في من يقف خلف OPA: في 20 أغسطس 2025 انتقل منشئو OPA وعدد من فريق Styra إلى Apple، وقال المشرفون في رسالتهم للمجتمع إن OPA يبقى مشروعاً متخرجاً Graduated في CNCF دون أي تغيير في الحوكمة أو الترخيص، وإن توزيعة Styra التجارية EOPA وOPA Control Plane والأداة Regal تنتقل إلى منظمة OPA مفتوحة المصدر. ومن نتائج ذلك أن ترشيح البيانات إلى SQL الذي كان في EOPA وحدها نقل إلى OPA في الإصدار 1.9.0 في 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 لترشيح القوائم، وعميلان: واحد في شبكة «المكتب» وواحد خارجها. نبدأ بالمجلدات:
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 في نسخته النهائية:
# 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، حتى يغيرها فريق الدعم دون أن يلمس القواعد:
{
"limits": { "agent": 500, "senior": 3000, "lead": 20000 },
"mfa_above": 1000
}في السياسة أعلاه لاحظ التالي:
- السياسة مكتوبة كقائمة أسباب للرفض
deny، والسماح يعني ألا يوجد أي سبب، وميزة هذا الشكل أن الخدمة تحصل مع كل رفض على السبب، فتعرضه للموظف وتكتبه في السجل. amrحقل معرف في OpenID Connect يذكر طرق المصادقة التي استخدمها المستخدم، وقد شرحنا التوكن وحقوله في شرح OpenID Connect، وهو مثال على خاصية لا يعرفها إلا مزود الهوية، فتأكد أن مزودك يضعه في التوكن قبل أن تبني عليه قاعدة.- الساعة تحسب بتوقيت
Asia/Riyadhصراحة، لأن الوقت في الطلب بتوقيت UTC، والساعة الثامنة صباحاً في الرياض هي الخامسة بتوقيت UTC، وقد شرحنا هذا الخلط في التاريخ والوقت للمطورين. - الحدود تقرأ من
data.refunds.limits، لأن ملف البيانات داخل المجلدrefunds. وفي النسخة الأولى كتبناdata.limitsونجحت الاختبارات لأننا شغلناها من داخل المجلد، ثم نبهنا Regal إلى مرجع غير معرف Unresolved Reference، فعند بناء الحزمة من المجلدpolicyيصبح المسارdata.refunds.limits، وكانت كل حدود المبالغ ستختفي بصمت.
الاختبارات، وفخ قائمة الرفض
الاختبارات في Rego قواعد يبدأ اسمها ب test_، وقد رأينا شكلها في المقال الأول، وهذا الملف policy/refunds/refunds_test.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، وهذه نتيجة اختبارها:
docker run --rm -v "$PWD/policy:/p" -w /p openpolicyagent/opa:1.21.1 test refunds/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 إذا قرأت قاعدة قيمة غير موجودة، مثل input.order.region والطلب غير موجود، فإن القاعدة كلها تصبح غير معرفة Undefined ولا تضيف شيئاً إلى المجموعة deny، فتصبح المجموعة فارغة ويصبح count(deny) == 0 صحيحاً. وهذا هو الفرق الخطير بين قائمة السماح وقائمة الرفض: في قائمة السماح البيانات الناقصة ترفض، وفي قائمة الرفض تسمح. والحل الذي في النسخة النهائية أن تحول غياب كل خاصية إلى سبب رفض صريح، ولذلك أضفنا أيضاً «لا يوجد حد لهذا المستوى» حتى لا يمر مستوى جديد اسمه manager لم يضف إلى ملف الحدود. والآن:
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 -1data.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، وهي التي نبهتنا إلى مسار البيانات الخاطئ:
docker run --rm -v "$PWD/policy:/p" -w /p ghcr.io/open-policy-agent/regal:0.43.0 lint --format compact .│ 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 مع اختباراته:
# 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 allowpackage 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:
# 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 دورياً كما في توثيق الحزم. والتوقيع يضمن أن OPA لا تقبل إلا حزمة بنيتها أنت، والصورة التالية تبين الطريق كاملاً:

الملف policy/.manifest يحدد الجذور Roots التي تملكها الحزمة، أي المسارات في data التي لا يكتب فيها غيرها، وسياسة إخفاء البيانات في السجل policy/system/log.rego سوف نشرحها في قسم السجلات:
{"roots": ["refunds", "gateway", "envoy", "filters", "system"]}ننشئ زوج مفاتيح RSA ونبني الحزمة الموقعة، دون ملفات الاختبار:
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/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:
# 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:
# 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 برنامج صغير يطبع ما يصله:
# 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 كاملاً، وبقية الملفات الصغيرة:
# 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
# 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
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
# 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: التطبيق وأدوات الطلب والقياس
server {
listen 8080;
default_type application/json;
location / { return 200 '{"upstream": "orders-app", "path": "$uri", "x_policy": "$http_x_policy"}\n'; }
}
# 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]}")
# 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")
{"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"}
}}
بعد أن تضع سطور المفتاح العام في الملفين، نشغل كل شيء ونتأكد أن الحزمة فعلت:
docker compose up -d
sleep 15; docker logs lab-authz-opa-logs 2>&1 | grep status | tail -1status {"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 يبني ويوقع ويرفع، ثم تسحب كل النسخ.
نكسرها: حزمة معدلة في الطريق
لنفرض أن شخصاً وصل إلى سيرفر الحزم فعدل سياسة البوابة لتسمح بكل شيء، وأعاد ضغط الحزمة مع ملف التوقيع القديم:
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ومن تقرير الحالة الحقول المهمة:
'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 بعد نشر جديد، فهذا يعني أن نسخة ما تعمل بسياسة قديمة. ثم نعيد الحزمة الصحيحة:
cp good.tar.gz bundles/authz.tar.gz && rm -rf /tmp/evil good.tar.gzسجل القرارات، دون البيانات الشخصية
الخدمة تسأل OPA عن قرار المرتجعات بطلب REST كما في المقال الأول، مع المستخدم والطلب والوقت:
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{"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 إلى طرق المصادقة سمح له. وهذا ما وصل إلى الجامع عن الطلب الأول:
{"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كما في توثيق سجل القرارات، فالسجل يقول ماذا قرر المحرك ولماذا، دون أن يحمل بيانات لا يحتاجها المدقق. - كل قرار يحمل رقم نسخة الحزمة التي اتخذته، فإذا سألك أحد لماذا رفض طلب يوم الأحد تعرف بالضبط أي نسخة من السياسة كانت تعمل.
- القرار الأول أخذ 2.6 ملي ثانية في المحرك لأن الاستعلام ترجم لأول مرة، والثاني أخذ 0.42 لأن
counter_server_query_cache_hitأصبح 1، أي أن الاستعلام المترجم حفظ، وهذا ليس Cache للقرار، فكل مدخل جديد يقيم من جديد.
# 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، والخيار failure_mode_allow: false في إعداد Envoy يعني أن تعطل OPA يرفض الطلب، وهو الافتراضي كما في توثيق المرشح ولكننا كتبناه صراحة. والصورة التالية تبين أين تقع البوابتان بالنسبة للخدمة، مع الأزمنة التي قسناها:

نرسل الطلبات الثلاثة نفسها عبر البوابتين، مرة من العميل في شبكة المكتب ومرة من العميل الخارجي:
for c in client outside; do echo "--- $c"; docker exec lab-authz-opa-$c python /lab/try.py; done--- 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 رأينا السبب:
{'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. وفي التجربة أزلنا المنافذ المنشورة ووضعنا عميلين في شبكتين مختلفتين، فأصبحت النتيجة كما في المخرج السابق.
نكسرها: نوقف OPA
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-envoyapisix 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، والطلب كاملاً عبر كل بوابة:
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│ 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أبطأ، لذلك اقرأ توثيق الأداء وقس سياستك أنت بالأمرopa benchفي CI حتى تعرف إذا أبطأها تعديل.
ترشيح القوائم: من السياسة إلى شرط SQL
شاشة «طلبات المرتجعات» تحتاج كل الطلبات التي يستطيع الموظف ردها، وOPA لا تحفظ الطلبات، فلا يوجد عندها ListObjects كما في OpenFGA. والحل أن نعطي OPA ما نعرفه، أي المستخدم، ونقول لها إن الطلب نفسه غير معروف Unknown، فتقيم السياسة جزئياً Partial Evaluation وتعيد الشرط الذي يجب أن يحققه الطلب، مترجماً إلى SQL، كما في درس الترشيح بلغة SQL. وهذه سياسة policy/filters/filters.rego، والتعليق في أعلاها يحدد أن input.orders غير معروف:
# 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 وثالث ليس من الدعم:
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');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{"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')"}}
{}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" id | amount
--------+--------
o-1041 | 120
o-1045 | 450
(2 rows)في المخرج أعلاه لاحظ التالي:
- OPA حسبت حد المستوى من البيانات، 500 أو 3000، ووضعته في الشرط، فالخدمة لا تعرف شيئاً عن الحدود.
- الرد الفارغ
{}لموظف المبيعات يعني أنه لا يوجد أي شرط يجعله يرى شيئاً، فلا تقم بتشغيل الاستعلام دون شرط في هذه الحالة، وإنما أعد قائمة فارغة، وهذا ما يذكره الدرس الرسمي صراحة. - ليست كل سياسة قابلة للترجمة إلى SQL، فالتوثيق يحدد ما يمكن أن يظهر في السياسة التي تترجم، لذلك نكتب سياسة الترشيح منفصلة وبسيطة، ونبقي سياسة القرار على الطلب الواحد كاملة، فالقائمة تعرض والقرار يفحص عند الرد الفعلي.
إزالة التجربة
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، فتعرض القائمة ما يسمح به المحرك دون أن تحمل الخدمة قواعده.
دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.
دليلمنصة صلاحيات مركزية لخدماتك Microservicesكيف تجعل كل خدماتك تسأل نقطة قرار واحدة، وتبقي علاقاتها متزامنة مع بياناتك، وماذا يحدث عندما يتأخر سحب الصلاحية.
دليلOpenFGA عملياً: نموذج العلاقات من التصميم إلى الإنتاجكيف تصمم نموذج علاقات في OpenFGA للمؤسسة والفرق والمجلدات، وتختبره وتغيره وتشغله على PostgreSQL دون مفاجآت.
دليلApache APISIX عملياً: من أول Route إلى بوابة إنتاجكيف تشغل Apache APISIX مع etcd، وتحمي واجهاتك بالتوكنات والحدود وauthentik، وتراقبها وتأخذ نسخة احتياطية من إعدادها.سجل التحديثات
- أكتوبر 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.