في المقال الأول من السلسلة كتبنا نموذجاً صغيراً في OpenFGA وسألناه بأمر check، وفي منصة صلاحيات مركزية لخدماتك جعلناه نقطة قرار لعدة خدمات وقسنا زمنه واتساقه. وفي هذا المقال نترك المنصة وننظر داخل المحرك نفسه، لأن أغلب المشكلات التي تقابلها بعد أشهر من التشغيل لا تأتي من الشبكة ولا من الخدمات، وإنما من النموذج: علاقة صممت بشكل يعطي صلاحية أوسع مما تريد، أو شرط ينقصه سياق، أو نموذج جديد نشر فغير القرارات دون أن يلاحظ أحد.
المواضيع في السلسلة:
- الصلاحيات المركزية Authorization: النماذج والمصطلحات والمحركات.
- منصة صلاحيات مركزية لخدماتك: المنصة والمزامنة والاتساق.
- OpenFGA عملياً (الموضوع الحالي).
- OPA عملياً: سياسات الخصائص ABAC.
وسوف نناقش في هذا المقال ما يلي:
- تشغيل OpenFGA على PostgreSQL بمفتاح للواجهة ومقاييس، وما الجداول التي ينشئها.
- تصميم نموذج لمؤسسة وفرق ومجلدات متداخلة ومستندات، مع الوراثة والاستثناء بالحجب، وصلاحية مؤقتة بشرط Condition.
- العلاقات السياقية Contextual Tuples، ومتى تستخدمها، ولماذا لا تقبلها من المتصفح.
- الأسئلة العكسية ListObjects وListUsers، والاختبارات التي تعمل في CI.
- تغيير النموذج: لماذا كل نموذج جديد له معرف جديد، وكيف فتح حذف علاقة واحدة مستنداً محجوباً في تجربتنا.
- ترقية OpenFGA ونسخه الاحتياطي، والمكتبة الرسمية بلغة Python، والمراقبة.
OpenFGA على PostgreSQL، ومفتاح للواجهة
قاعدة البيانات في الذاكرة التي استخدمناها في المقال الأول تختفي عند إعادة التشغيل، أما في الإنتاج فيحفظ OpenFGA العلاقات والنماذج في PostgreSQL أو MySQL أو SQLite كما في دليل التشغيل الرسمي. نفذنا التجربة على Docker Engine 29.8.0 بإصدار OpenFGA 1.21.0 أولاً ثم رقيناه إلى 1.22.0 في قسم الترقية، مع fga CLI 0.8.1 وPostgreSQL 18.6، وكل ما ننشئه يبدأ اسمه بالبادئة lab-authz-fga. نبدأ بملف .env:
mkdir -p ~/openfga-lab && cd ~/openfga-lab# Lab-only secrets. On a real server: openssl rand -hex 32
PG_PASS=lab-authz-fga-pg-71d3
FGA_KEY=lab-authz-fga-key-2b8e6c0a9f
# The upgrade step changes this to v1.22.0
FGA_VERSION=v1.21.0ثم compose.yaml:
# OpenFGA on PostgreSQL with an API key, metrics and the CLI image for tests.
name: lab-authz-fga
services:
pg:
image: postgres:18.6
container_name: lab-authz-fga-pg
environment: { POSTGRES_USER: openfga, POSTGRES_PASSWORD: "${PG_PASS}", POSTGRES_DB: openfga }
volumes: [pg-data:/var/lib/postgresql]
healthcheck: { test: ["CMD", "pg_isready", "-U", "openfga"], interval: 3s, retries: 20 }
migrate:
image: openfga/openfga:${FGA_VERSION}
container_name: lab-authz-fga-migrate
command: migrate
environment: &ds
OPENFGA_DATASTORE_ENGINE: postgres
OPENFGA_DATASTORE_URI: "postgres://openfga:${PG_PASS}@lab-authz-fga-pg:5432/openfga?sslmode=disable"
depends_on: { pg: { condition: service_healthy } }
openfga:
image: openfga/openfga:${FGA_VERSION}
container_name: lab-authz-fga
command: run
environment:
<<: *ds
OPENFGA_AUTHN_METHOD: preshared
OPENFGA_AUTHN_PRESHARED_KEYS: "${FGA_KEY}"
OPENFGA_LOG_FORMAT: json
OPENFGA_METRICS_ENABLE_RPC_HISTOGRAMS: "true"
OPENFGA_DATASTORE_METRICS_ENABLED: "true"
ports:
- "127.0.0.1:18180:8080" # HTTP API
- "127.0.0.1:18212:2112" # Prometheus metrics
depends_on: { migrate: { condition: service_completed_successfully } }
volumes:
pg-data:
name: lab-authz-fga-pg-data
networks:
default:
name: lab-authz-fga-netفي الإعداد أعلاه لاحظ التالي:
- الخدمة
migrateتشغل الأمرopenfga migrateالذي ينشئ الجداول أو يرقيها إلى إصدار البرنامج، والمحرك لا يبدأ إلا بعد نجاحها، وسوف نرى أهمية ذلك عند الترقية. - الواجهة البرمجية في OpenFGA مفتوحة لأي أحد افتراضياً، فقيمة
--authn-methodالافتراضية هيnoneكما يظهر فيopenfga run --help، لذلك فعلنا المفتاح المشتركpreshared، وفي الإنتاج تستطيع أن تستخدمoidcفيتحقق المحرك من توكنات مزود الهوية نفسه. والسبب أن من يصل إلى واجهة الكتابة يستطيع أن يكتب لنفسه أي علاقة، فيصبح مالكاً لأي مستند. - المقاييس على المنفذ 2112 بصيغة Prometheus، وفعلنا فيها توزيع زمن الطلبات ومقاييس قاعدة البيانات.
- لا يوجد هنا منفذ للواجهة الرسومية Playground، فهي منذ الإصدار 1.14.0 الصادر في 3 أبريل 2026 معطلة افتراضياً ومعلنة كميزة ستزال، بعد الثغرة GHSA-68m9-983m-f3v5 كما في سجل التغييرات، لذلك نعمل في هذا المقال بأداة سطر الأوامر والملفات.
docker compose up -d
docker logs lab-authz-fga-migrate 2>&1 | tail -3
docker exec lab-authz-fga-pg psql -U openfga -c '\dt'
curl -s 127.0.0.1:18180/stores; echo
curl -s -H 'Authorization: Bearer lab-authz-fga-key-2b8e6c0a9f' 127.0.0.1:18180/stores; echoINFO db info {"current version": 0}
INFO running all migrations
INFO migration done
List of tables
Schema | Name | Type | Owner
--------+---------------------+-------+---------
public | assertion | table | openfga
public | authorization_model | table | openfga
public | changelog | table | openfga
public | goose_db_version | table | openfga
public | store | table | openfga
public | tuple | table | openfga
(6 rows)
{"code":"bearer_token_missing","message":"missing bearer token"}
{"stores":[],"continuation_token":""}الجدول tuple فيه العلاقات، وauthorization_model فيه كل نموذج كتبته، وchangelog سجل كل كتابة وحذف، وgoose_db_version يحفظ إصدار مخطط قاعدة البيانات الذي وصلت إليه. وأداة سطر الأوامر نشغلها من Docker Image الرسمية بدالة صغيرة في الملف fga.sh:
# fga CLI from its official image, on the lab network, with the API key from .env
fga() { docker run --rm -i --network lab-authz-fga-net -v "$PWD:/work" -w /work \
-e FGA_API_URL=http://lab-authz-fga:8080 -e FGA_API_TOKEN="$FGA_KEY" -e FGA_STORE_ID="$FGA_STORE_ID" \
-e FGA_MODEL_ID="$FGA_MODEL_ID" openfga/cli:v0.8.1 "$@"; }تصميم النموذج: مؤسسة وفريق ومجلدات ومستندات
المتطلبات التي نصممها: المؤسسة acme لها مديرة اسمها noura، وفريق المالية فيه alice وbob، ومجلد المالية يملكه أعضاء الفريق قراءة، وبداخله مجلد 2026 فيه مستندان، والمستند salaries محجوب عن bob تحديداً حتى لو كان في الفريق، ومدقق خارجي اسمه khalid يقرأ مجلد المالية ساعة واحدة فقط. وهذا النموذج model.fga:
model
schema 1.1
type user
type organization
relations
define admin: [user]
define member: [user] or admin
type team
relations
define org: [organization]
define member: [user]
type folder
relations
define org: [organization]
define parent: [folder]
define owner: [user, team#member]
define editor: [user, team#member] or owner or editor from parent
define viewer: [user, team#member, user with not_expired] or editor or viewer from parent
define can_delete: owner or admin from org or can_delete from parent
type document
relations
define folder: [folder]
define owner: [user]
define blocked: [user]
define editor: [user] or owner or editor from folder
define viewer: ([user, user with not_expired] or editor or viewer from folder) but not blocked
define can_delete: owner or can_delete from folder
condition not_expired(current_time: timestamp, grant_time: timestamp, grant_duration: duration) {
current_time < grant_time + grant_duration
}والصورة التالية تبين العلاقات التي سوف نكتبها على هذا النموذج:

في النموذج أعلاه لاحظ التالي:
[user, team#member]في علاقةviewerتعني أن القارئ يمكن أن يكون مستخدماً منفرداً أو كل أعضاء فريق، وال#memberهنا مجموعة مستخدمين Userset، فإذا أضفت شخصاً إلى الفريق قرأ كل ما يقرؤه الفريق دون أي علاقة جديدة على المجلدات.editor from parentوviewer from parentهي الوراثة عبر المجلدات المتداخلة، وتسمى في التوثيق Tuple to Userset، فمن يقرأ المجلد الأب يقرأ المجلد الابن وكل ما بداخله.can_deleteفي المجلد تعطي مديرة المؤسسة حق الحذف، ولكنها لا تعطيها حق القراءة، لأنviewerلا يذكرadmin from org. وهذا فصل مقصود: المديرة تستطيع أن تنظف مجلدات موظف غادر دون أن تقرأ رواتب الجميع، فلا تجعل الصلاحيات الإدارية تشمل القراءة تلقائياً لمجرد أنها أسهل في الكتابة.but not blockedهو الاستثناء Exclusion: كل من يقرأ عبر أي طريق، إلا من كتبت له علاقةblockedعلى هذا المستند. والأقواس تجمع الطرق الثلاثة في تعبير واحد، ثم يطبق عليه الاستثناء.user with not_expiredعلاقة لها شرط Condition مكتوب بلغة CEL في آخر الملف، والعلاقة لا تكون صالحة إلا إذا كان الشرط صحيحاً وقت السؤال، كما في توثيق الشروط.
ننشئ المخزن Store بالنموذج، ونحفظ المعرفين، ثم نكتب العلاقات من الملف tuples.yaml:
# The organisation and its finance team
- user: user:noura
relation: admin
object: organization:acme
- user: organization:acme
relation: org
object: team:finance
- user: user:alice
relation: member
object: team:finance
- user: user:bob
relation: member
object: team:finance
# Folders: finance belongs to acme, finance-2026 sits inside it
- user: organization:acme
relation: org
object: folder:finance
- user: user:alice
relation: owner
object: folder:finance
- user: team:finance#member
relation: viewer
object: folder:finance
- user: folder:finance
relation: parent
object: folder:finance-2026
# Documents
- user: folder:finance-2026
relation: folder
object: document:budget
- user: user:alice
relation: owner
object: document:budget
- user: folder:finance-2026
relation: folder
object: document:salaries
- user: user:bob
relation: blocked
object: document:salaries
# An external auditor who may read the finance folder for one hour only
- user: user:khalid
relation: viewer
object: folder:finance
condition:
name: not_expired
context:
grant_time: "2026-10-12T12:00:00Z"
grant_duration: 1hset -a; . ./.env; set +a; . ./fga.sh
fga store create --name docs --model model.fga
export FGA_STORE_ID=01M4R1RT6EJCMAQ3P7KBY8K9R3 FGA_MODEL_ID=01M4R1RT71B0915W7XK6HAD76T
fga tuple write --file tuples.yaml{"store":{"created_at":"2026-10-12T12:35:30.639219Z","id":"01M4R1RT6EJCMAQ3P7KBY8K9R3","name":"docs","updated_at":"2026-10-12T12:35:30.639219Z"},"model":{"authorization_model_id":"01M4R1RT71B0915W7XK6HAD76T"}}
{"failed_count":0,"successful":[{"object":"organization:acme","relation":"admin","user":"user:noura"},{"object":"team:finance","relation":"org","user":"organization:acme"},...وفي العلاقة الأخيرة في الملف لاحظ أن الشرط يحمل جزءاً من سياقه: وقت المنح ومدته محفوظان مع العلاقة، أما الوقت الحالي فيرسله التطبيق مع كل سؤال.
نسأل المحرك
fga query check user:bob viewer document:budget
fga query check user:bob viewer document:salaries
fga query check user:bob editor document:budget
fga query check user:noura can_delete document:budget
fga query check user:khalid viewer document:budget --context '{"current_time":"2026-10-12T12:30:00Z"}'
fga query check user:khalid viewer document:budget --context '{"current_time":"2026-10-12T13:30:00Z"}'{"allowed":true,"resolution":""}
{"allowed":false,"resolution":""}
{"allowed":false,"resolution":""}
{"allowed":true,"resolution":""}
{"allowed":true,"resolution":""}
{"allowed":false,"resolution":""}bob يقرأ الميزانية عبر فريقه والمجلد الأب، ولا يقرأ الرواتب لأنه محجوب، ولا يحرر الميزانية، وnoura تحذفها، وkhalid يقرؤها الساعة 12:30 ولا يقرؤها الساعة 13:30 لأن ساعته انتهت الساعة 13:00.
نكسرها: سؤال دون سياق الشرط
ماذا يحدث إذا نسي التطبيق إرسال الوقت؟
fga query check user:khalid viewer document:budget
fga query list-objects user:khalid viewer documentError: check failed: POST validation error for Check POST with body {"code":"validation_error","message":"failed to evaluate relationship condition: 'not_expired' - tuple 'folder:finance#viewer@user:khalid' is missing context parameters '[current_time]'"}
Error: failed to list objects due to POST validation error for ListObjects POST with body {"code":"validation_error","message":"Internal Server Error"}في المخرج أعلاه لاحظ أن check يقول بالضبط ما الذي ينقص، أما list-objects في الإصدار 1.21.0 فرد برسالة عامة لا تذكر السبب، فإذا رأيت هذه الرسالة في قائمة تستخدم شروطاً فابحث أولاً عن سياق ناقص. والأهم أن الخطأ يجب أن يعامل في تطبيقك كرفض، ولا تقم بإرسال قيمة افتراضية للوقت مثل تاريخ قديم حتى يختفي الخطأ، لأن الوقت القديم يجعل كل صلاحية مؤقتة صالحة إلى الأبد.
ويجدر الإشارة هنا إلى أن الشروط وال Cache اجتمعا في ثغرة CVE-2026-33729 التي أصلحت في الإصدار 1.13.1 في 24 مارس 2026، حيث كانت طلبات Check مع الشروط وتفعيل ال Cache قد تعيد نتيجة محفوظة غير صحيحة، لذلك تابع إصدارات المحرك مثل أي مكون أمني.
العلاقات السياقية Contextual Tuples
ليست كل حقيقة يحتاجها القرار مخزنة في المحرك، فعضوية المجموعات مثلاً تأتي في التوكن من مزود الهوية، ونسخها إلى المحرك يعني مزامنة أخرى. والبديل أن ترسلها الخدمة مع السؤال كعلاقة سياقية Contextual Tuple، فيستخدمها المحرك لهذا الطلب فقط ولا يحفظها، كما في توثيق العلاقات السياقية. والصورة التالية تبين المصادر الثلاثة للحقائق في طلب واحد:
![مخطط: خدمتك تقرأ groups: [finance] من التوكن وترسل طلب Check واحداً إلى OpenFGA فيه user:carol وrelation: viewer وobject: document:budget، ومعه contextual_tuples فيها user:carol member team:finance، وcontext فيه current_time، وOpenFGA يقرأ أيضاً العلاقات المخزنة من PostgreSQL، والعلاقة السياقية تستخدم لهذا الطلب فقط ولا تحفظ](https://arabroot.io/content/images/2026/10/openfga-practice-02-request-facts.webp)
fga query check user:carol viewer document:budget
fga query check user:carol viewer document:budget --contextual-tuple "user:carol member team:finance"{"allowed":false,"resolution":""}
{"allowed":true,"resolution":""}carol ليست في الفريق في المحرك، ولكن عندما قالت الخدمة إن التوكن يذكر أنها في فريق المالية قرأت المستند. وهذا مفيد، وهو أيضاً خطير بالقدر نفسه، والسبب أن العلاقة السياقية تقبل دون أي فحص، فإذا أخذتها الخدمة من طلب المستخدم نفسه، مثل حقل في JSON أو ترويسة يرسلها المتصفح، فإن أي مستخدم يستطيع أن يكتب لنفسه أي عضوية. لذلك لا تبن العلاقة السياقية إلا من توكن تحققت من توقيعه، أو من بيانات خدمتك، وتذكر أن قرار المحرك حينها لا يكون أحدث من التوكن، فإذا أخرج موظف من المجموعة بقيت عضويته في التوكن حتى ينتهي.
الأسئلة العكسية: ListObjects وListUsers
check يسأل عن مستخدم ومورد، أما في الواجهات فتحتاج غالباً إلى السؤالين العكسيين: ما المستندات التي يراها bob؟ ومن يستطيع أن يرى مستند الرواتب؟
fga query list-objects user:bob viewer document
fga query list-objects user:khalid viewer document --context '{"current_time":"2026-10-12T12:30:00Z"}'
fga query list-users --object document:budget --relation viewer --user-filter user --context '{"current_time":"2026-10-12T12:30:00Z"}'
fga query list-users --object document:salaries --relation viewer --user-filter user --context '{"current_time":"2026-10-12T13:30:00Z"}'{"objects":["document:budget"]}
{"objects":["document:budget","document:salaries"]}
{"users":[{"object":{"id":"bob","type":"user"}},{"object":{"id":"alice","type":"user"}},{"object":{"id":"khalid","type":"user"}}]}
{"users":[{"object":{"id":"alice","type":"user"}}]}الإجابة الأخيرة هي ما يسأله المدقق: من يستطيع رؤية الرواتب الساعة 13:30؟ alice وحدها، فbob محجوب وkhalid انتهت ساعته، وnoura لا تقرأ. وللسؤالين حدود افتراضية، ألف نتيجة وثلاث ثوان، وقد رأينا في المنصة كيف قطعت قائمة alice عند ألف مستند دون خطأ، لذلك اقرأ توثيق الأسئلة قبل أن تبني عليها صفحة. وأما مستوى الاتساق لكل سؤال، MINIMIZE_LATENCY أو HIGHER_CONSISTENCY، فقد قسنا أثره في المنصة.
اختبار النموذج في CI
النموذج أعلاه فيه قرارات تصميم كثيرة، وكل واحد منها يمكن أن يكسره تعديل صغير، لذلك نكتب ما نتوقعه في ملف اختبار store.fga.yaml يقرأ النموذج والعلاقات نفسها، كما في توثيق الاختبار:
name: docs
model_file: ./model.fga
tuple_file: ./tuples.yaml
tests:
- name: the finance team reads the folder and everything under it
check:
- user: user:bob
object: document:budget
assertions: { viewer: true, editor: false, can_delete: false }
- user: user:alice
object: document:budget
assertions: { editor: true, can_delete: true }
- name: a blocked user loses access even through the team
check:
- user: user:bob
object: document:salaries
assertions: { viewer: false }
- name: the org admin can delete, but cannot read
check:
- user: user:noura
object: document:budget
assertions: { can_delete: true, viewer: false }
- name: the auditor's access ends after one hour
check:
- user: user:khalid
object: document:budget
context: { current_time: "2026-10-12T12:59:00Z" }
assertions: { viewer: true }
- user: user:khalid
object: document:budget
context: { current_time: "2026-10-12T13:01:00Z" }
assertions: { viewer: false }
- name: a new team member reads the budget (tuples added for this test only)
tuples:
- { user: user:carol, relation: member, object: team:finance }
check:
- user: user:carol
object: document:budget
assertions: { viewer: true, editor: false }
- name: who can read the salaries document
list_users:
- object: document:salaries
user_filter: [{ type: user }]
context: { current_time: "2026-10-12T12:30:00Z" }
assertions:
viewer:
users: [user:alice, user:khalid]
list_objects:
- user: user:bob
type: document
assertions:
viewer: [document:budget]fga model test --tests store.fga.yaml# Test Summary #
Tests 6/6 passing
Checks 12/12 passing
ListObjects 1/1 passing
ListUsers 1/1 passingفي الملف أعلاه لاحظ أن الاختبار يمكن أن يضيف علاقات لنفسه فقط بالحقل tuples، مثل إضافة carol إلى الفريق، وأن الاختبارات تختبر ما لا يجب أن يحدث أيضاً، مثل أن مديرة المؤسسة لا تقرأ. وقد حاولنا في البداية أن نكتب contextual_tuples داخل الفحص، فرفضت الأداة الملف برسالة field contextual_tuples not found in type storetest.ModelTestCheck، فهذا الحقل غير موجود في صيغة ملف الاختبار في الإصدار 0.8.1.
نكسرها: نحذف الاستثناء من السطر
لنفرض أن مطوراً رأى السطر طويلاً فبسطه وحذف but not blocked:
- define viewer: ([user, user with not_expired] or editor or viewer from folder) but not blocked
+ define viewer: [user, user with not_expired] or editor or viewer from folder(FAILING) a blocked user loses access even through the team: Checks (0/1 passing)
ⅹ Check(user=user:bob,relation=viewer,object=document:salaries): expected=false, got=true
---
(FAILING) who can read the salaries document: ListObjects (0/1 passing) | ListUsers (0/1 passing)
ⅹ ListObjects(user=user:bob,relation=viewer,type=document): expected=[document:budget], got=[document:salaries document:budget]
ⅹ ListUsers(object={Type:document Id:salaries},relation=viewer,user_filter={Type:user Relation:<nil>}, context:&map[current_time:2026-10-12T12:30:00Z]): expected={Users:[user:alice user:khalid]}, got={Users:[user:khalid user:bob user:alice]}
---
# Test Summary #
Tests 4/6 passing
Checks 11/12 passing
ListObjects 0/1 passing
ListUsers 0/1 passingثلاثة اختبارات فشلت، وكلها تقول الشيء نفسه بثلاث طرق: bob أصبح يقرأ الرواتب. وهذا الاختبار لا يحتاج إلى سيرفر، فيعمل في خط CI عند كل طلب دمج، وقد شرحنا بناء هذا الخط في خط CI/CD آمن.
تغيير النموذج: كل تعديل نموذج جديد
النماذج في OpenFGA لا تعدل، فكل كتابة لنموذج تنشئ نموذجاً جديداً بمعرف جديد، والقديم يبقى، وكل طلب إما أن يحدد معرف النموذج الذي يريده، وإما أن يستخدم أحدث نموذج. والصورة التالية تبين ما حدث في التجربة عبر أربعة نماذج:

أولاً تعديل آمن يضيف علاقة جديدة commenter:
unset FGA_MODEL_ID # like a service that does not pin the model: it uses the latest one
sed 's/ define can_delete: owner or can_delete from folder/ define commenter: [user] or editor\n define can_delete: owner or can_delete from folder/' model.fga > model-v2.fga
fga model write --file model-v2.fga
fga model list
FGA_MODEL_ID=01M4R1RT71B0915W7XK6HAD76T fga query check user:alice commenter document:budget
fga query check user:alice commenter document:budget{"authorization_model_id":"01M4R1VKGK7KER19MNWSQN4YDC"}
{"authorization_models":[{"id":"01M4R1VKGK7KER19MNWSQN4YDC","created_at":"2026-10-12T12:37:02.099Z"},{"id":"01M4R1RT71B0915W7XK6HAD76T","created_at":"2026-10-12T12:35:30.657Z"}]}
Error: check failed: POST validation error for Check POST with body {"code":"validation_error","message":"invalid relation: relation 'document#commenter' not found"}
{"allowed":true,"resolution":""}الخدمة المثبتة على النموذج الأول لا تعرف العلاقة الجديدة، والخدمة التي لا تحدد نموذجاً استخدمت الجديد مباشرة. والآن تعديل يبدو تنظيفاً: فريق قرر أن الحجب لم يعد مطلوباً فحذف العلاقة blocked من النموذج:
sed -e '/define blocked: \[user\]/d' \
-e 's/(\[user, user with not_expired\] or editor or viewer from folder) but not blocked/[user, user with not_expired] or editor or viewer from folder/' \
model-v2.fga > model-v3.fga
fga model write --file model-v3.fga
fga query check user:bob viewer document:salaries
fga tuple read --object document:salaries --relation blocked
fga tuple write user:carol blocked document:salaries{"authorization_model_id":"01M4R1VZQ4NYTEGP09TQ5XQ08G"}
{"allowed":true,"resolution":""}
{
"continuation_token":"",
"tuples": [
{
"key": {
"object":"document:salaries",
"relation":"blocked",
"user":"user:bob"
},
"timestamp":"2026-10-12T12:35:40.96315Z"
}
]
}
Error: failed to write tuple: POST validation error for Write POST with body {"code":"validation_error","message":"Invalid tuple 'document:salaries#blocked@user:carol'. Reason: relation 'document#blocked' not found"}في المخرج أعلاه لاحظ التالي:
- bob أصبح يقرأ الرواتب في اللحظة التي كتب فيها النموذج الجديد، دون أي تغيير في العلاقات، لأن كل خدمة لا تثبت معرف النموذج انتقلت إليه فوراً.
- علاقة الحجب ما زالت في الجدول، والمحرك يتجاهلها لأن النموذج لم يعد يعرف العلاقة، ولن يقبل كتابة علاقة جديدة من هذا النوع.
- لا يوجد «تراجع» عن نموذج، فالرجوع يعني أن تكتب محتوى النموذج القديم من جديد، فيأخذ معرفاً رابعاً، كما في الأمرين التاليين.
fga model write --file model-v2.fga
fga query check user:bob viewer document:salaries{"authorization_model_id":"01M4R1WCQKJ23ED8SY9PX066DT"}
{"allowed":false,"resolution":""}وعاد bob محجوباً، لأن علاقة الحجب لم تحذف من الجدول أصلاً.
والقواعد التي نخرج بها: كل خدمة تثبت معرف النموذج في إعدادها، ويتغير المعرف بنشر مثل أي إعداد، والنموذج الجديد يمر بالاختبارات قبل الكتابة، وعند حذف علاقة أو إعادة تسميتها تكتب العلاقات بالشكل الجديد أولاً، وتنقل الخدمات، ثم تحذف العلاقات القديمة، وهي الطريقة نفسها التي تغير بها عموداً في قاعدة بيانات تستخدمها عدة خدمات.
الترقية والنسخ الاحتياطي
كل ما في OpenFGA من حالة دائمة موجود في قاعدة البيانات، لذلك نسخه الاحتياطي هو نسخ قاعدة البيانات كما شرحنا في استعادة PostgreSQL إلى أي لحظة، وقبل أي ترقية خذ نسخة، لأن openfga migrate يغير المخطط. والآن نرقي من 1.21.0 إلى 1.22.0:
docker exec lab-authz-fga-pg pg_dump -U openfga -Fc openfga > openfga-before-upgrade.dump
sed -i 's/^FGA_VERSION=v1.21.0/FGA_VERSION=v1.22.0/' .env; set -a; . ./.env; set +a
docker compose up -d
docker logs lab-authz-fga-migrate 2>&1 | tail -3
fga query check user:bob viewer document:budgetINFO db info {"current version": 6}
INFO running all migrations
INFO migration done
{"allowed":true,"resolution":""}المخطط كان في الإصدار 6، ولم تحتج هذه الترقية إلى تغيير فيه، ولكن الأمر يعمل في كل مرة ولا يضر إذا لم يجد ما يفعله، لذلك اجعله خطوة ثابتة قبل تشغيل أي إصدار جديد. وفي عنقود من عدة نسخ شغل الترحيل مرة واحدة قبل تحديث النسخ، واقرأ سجل التغييرات للإصدارات التي تقفز عليها، فالإصدار 1.22.0 مثلاً غير توقيع دالة في حزمة MySQL لمن يضمن OpenFGA في برنامجه بلغة Go.
OpenFGA من داخل خدمة بلغة Python
الخدمة في الإنتاج تستخدم المكتبة الرسمية بدل أداة سطر الأوامر، وهذه خدمة صغيرة بالمكتبة openfga-sdk بإصدارها 0.10.5، تحول المجموعات من التوكن إلى علاقات سياقية وترسل الوقت الحالي كسياق للشرط، وتثبت معرف النموذج:
# How a service asks OpenFGA through the official Python SDK (sync client).
# The team membership that comes from the identity provider's token is sent as a contextual tuple:
# it is used for this request only and never stored.
import os, time
from datetime import datetime, timezone
from openfga_sdk.client import ClientConfiguration
from openfga_sdk.client.models import ClientCheckRequest, ClientListObjectsRequest, ClientTuple
from openfga_sdk.credentials import CredentialConfiguration, Credentials
from openfga_sdk.sync import OpenFgaClient
config = ClientConfiguration(
api_url="http://lab-authz-fga:8080",
store_id=os.environ["FGA_STORE_ID"],
authorization_model_id=os.environ["FGA_MODEL_ID"], # pin the model; never "latest" in production
credentials=Credentials(method="api_token",
configuration=CredentialConfiguration(api_token=os.environ["FGA_KEY"])))
def facts(user, groups):
"""Groups from the token become contextual tuples; the clock becomes the condition context."""
tuples = [ClientTuple(user=user, relation="member", object=f"team:{g}") for g in groups]
return tuples, {"current_time": datetime.now(timezone.utc).isoformat()}
with OpenFgaClient(config) as fga:
for user, groups in (("user:carol", ["finance"]), ("user:carol", []), ("user:khalid", [])):
tuples, ctx = facts(user, groups)
r = fga.check(ClientCheckRequest(user=user, relation="viewer", object="document:budget",
contextual_tuples=tuples, context=ctx))
print(f"check {user:12} groups={groups!s:12} viewer document:budget -> {r.allowed}")
tuples, ctx = facts("user:carol", ["finance"])
r = fga.list_objects(ClientListObjectsRequest(user="user:carol", relation="viewer", type="document",
contextual_tuples=tuples, context=ctx))
print("list user:carol groups=['finance'] ->", r.objects)
times = []
for _ in range(500):
t0 = time.perf_counter()
fga.check(ClientCheckRequest(user="user:bob", relation="viewer", object="document:budget"))
times.append((time.perf_counter() - t0) * 1000)
times.sort()
print(f"500 checks through the SDK: p50={times[250]:.2f}ms p95={times[475]:.2f}ms p99={times[495]:.2f}ms")docker run --rm --name lab-authz-fga-app --network lab-authz-fga-net -v "$PWD:/work" -w /work \
-e FGA_KEY -e FGA_STORE_ID=01M4R1RT6EJCMAQ3P7KBY8K9R3 -e FGA_MODEL_ID=01M4R1WCQKJ23ED8SY9PX066DT \
python:3.13.16-slim sh -c "pip install -q openfga-sdk==0.10.5; python app.py"check user:carol groups=['finance'] viewer document:budget -> True
check user:carol groups=[] viewer document:budget -> False
check user:khalid groups=[] viewer document:budget -> True
list user:carol groups=['finance'] -> ['document:budget', 'document:salaries']
500 checks through the SDK: p50=4.81ms p95=7.66ms p99=10.05msفي المخرج أعلاه لاحظ أن khalid يقرأ لأن التجربة نفذت الساعة 12:38 أي داخل ساعته، وأن carol ترى الرواتب أيضاً لأنها ليست محجوبة، والحجب في نموذجنا خاص ب bob. والمعرف ...6DT هو النموذج الرابع، أي v2 بعد إعادة كتابته.
المراقبة: ماذا تقرأ من المقاييس؟
بعد طلبات ال SDK قرأنا المقاييس من المنفذ 18212:
curl -s 127.0.0.1:18212/metrics | grep -E '^openfga_(datastore_query_count|dispatch_count)_(sum|count)|^grpc_server_handled_total|^openfga_check_cache'grpc_server_handled_total{grpc_code="OK",grpc_method="Check",grpc_service="openfga.v1.OpenFGAService",grpc_type="unary"} 504
grpc_server_handled_total{grpc_code="OK",grpc_method="ListObjects",grpc_service="openfga.v1.OpenFGAService",grpc_type="unary"} 1
openfga_check_cache_hit_count 0
openfga_check_cache_invalid_hit_count 0
openfga_check_cache_total_count 0
openfga_datastore_query_count_sum{grpc_method="check",grpc_service="openfga.v1.OpenFGAService"} 18625
openfga_datastore_query_count_count{grpc_method="check",grpc_service="openfga.v1.OpenFGAService"} 504
openfga_dispatch_count_sum{grpc_method="check",grpc_service="openfga.v1.OpenFGAService"} 2977
openfga_dispatch_count_count{grpc_method="check",grpc_service="openfga.v1.OpenFGAService"} 504
openfga_request_duration_ms_count{consistency="UNSPECIFIED",datastore_query_count="50",dispatch_count="50",grpc_method="check",grpc_service="openfga.v1.OpenFGAService"} 504في المخرج أعلاه لاحظ التالي:
- كل فحص ل bob على الميزانية احتاج في المتوسط إلى 37 استعلاماً في قاعدة البيانات (18625 ÷ 504) ونحو ستة تفرعات Dispatches، لأن النموذج يمشي الفريق والمجلدين والحجب والشرط. وهذا هو الرقم الذي تراقبه عندما يبطؤ المحرك: إذا زاد بعد تعديل النموذج فالتعديل جعل الطريق أطول.
- المقياس
openfga_request_duration_msموزع حسب عدد الاستعلامات والتفرعات، فتستطيع أن ترى هل البطء في كل الطلبات أم في الطلبات التي تمشي طرقاً طويلة فقط. - عدادات
openfga_check_cacheصفر لأن ال Cache غير مفعل هنا، وعندما تفعله راقب نسبة النجاح فيه، فإذا كانت منخفضة فأنت تدفع ثمن القرار القديم دون فائدة. - وهناك أيضاً
/healthzللفحص، وقد رد في التجربة ب{"status":"SERVING"}.
والسجل بصيغة JSON يكتب لكل طلب الطلب كاملاً، وهذا جزء من سطر حقيقي من التجربة:
{"level":"info","msg":"grpc_req_complete","grpc_method":"Check","user_agent":"openfga-cli/0.8.1","raw_request":{"store_id":"01M4R1RT6EJCMAQ3P7KBY8K9R3","tuple_key":{"user":"user:khalid","relation":"viewer","object":"document:budget"},"contextual_tuples":{"tuple_keys":[]},"authorization_model_id":"01M4R1RT71B0915W7XK6HAD76T","trace":false,"context":{},"consistency":"UNSPECIFIED"}}وهو مفيد في التحقيق، ولكنه يحمل معرفات المستخدمين والسياق، فعامله كسجل فيه بيانات شخصية من حيث الاحتفاظ والصلاحيات، كما شرحنا في السجلات Logging.
من غير هذه العلاقة؟ ReadChanges
الجدول changelog يحفظ كل كتابة وحذف، وتقرؤه بالأمر fga tuple changes، وهو ما تبني عليه مهمة المطابقة في المنصة. حذفنا عضوية bob ثم قرأنا تغييرات نوع team:
fga tuple delete user:bob member team:finance
fga tuple changes --type team
fga tuple write user:bob member team:financeTUPLE_OPERATION_WRITE user:bob member team:finance 2026-10-12T12:35:40.95398Z
TUPLE_OPERATION_WRITE user:alice member team:finance 2026-10-12T12:35:40.953732Z
TUPLE_OPERATION_WRITE organization:acme org team:finance 2026-10-12T12:35:40.953063Z
TUPLE_OPERATION_DELETE user:bob member team:finance 2026-10-12T15:02:01.142281Zاختصرنا المخرج إلى سطر لكل تغيير. ولاحظ أن السجل يقول ماذا تغير ومتى، ولا يقول من غيره، لأن المحرك لا يعرف المستخدم الذي ضغط زر المشاركة، فهذا يحفظ في خدمتك مع صف ال Outbox أو في سجل التدقيق.
إزالة التجربة
docker compose down -v
rm -rf ~/openfga-labوالأمر يحذف Containers المشروع وشبكته و Volume قاعدة البيانات lab-authz-fga-pg-data فقط، ولا يلمس أي شيء آخر على الجهاز.
الأخطاء الشائعة
- ترك الواجهة دون مصادقة: القيمة الافتراضية
none، ومن يصل إلى الكتابة يصبح مالكاً لما يريد. - خدمات لا تثبت معرف النموذج، فيغير نشر نموذج جديد قراراتها في اللحظة نفسها، كما فتح حذف
blockedالرواتب ل bob. - صلاحيات إدارية تشمل القراءة تلقائياً لأنها أسهل في الكتابة، مثل
viewer: ... or admin from org. - بناء علاقات سياقية من بيانات يرسلها المستخدم بدل التوكن المتحقق منه.
- إرسال سياق افتراضي للشرط حتى يختفي الخطأ، فتصبح الصلاحية المؤقتة دائمة.
- الاعتماد على Playground في الإنتاج، وهي معطلة افتراضياً وستزال.
- الترقية دون نسخة من قاعدة البيانات ودون تشغيل
openfga migrateأولاً. - نموذج بلا اختبارات، أو اختبارات تختبر السماح فقط ولا تختبر الرفض.
الخلاصة
- OpenFGA على PostgreSQL يحفظ العلاقات والنماذج وسجل التغييرات في ستة جداول، وواجهته مفتوحة افتراضياً، فاجعل لها مصادقة بمفتاح أو OIDC.
- النموذج يعبر عن الفرق كمجموعات مستخدمين، والمجلدات المتداخلة بالوراثة
from parent، والاستثناء بbut not، والصلاحية المؤقتة بشرط CEL يأخذ الوقت من الطلب، والسياق الناقص خطأ يعامل كرفض. - العلاقات السياقية تجلب حقائق من التوكن دون مزامنة، وتقبل دون فحص، لذلك لا تبنها إلا من توكن متحقق منه.
- ListObjects وListUsers يجيبان عن «ماذا يرى» و«من يرى»، واختبارات
fga model testكشفت حذف الاستثناء بثلاث طرق. - كل نموذج جديد له معرف جديد، والخدمة التي لا تثبت المعرف تتغير قراراتها فوراً، وحذف علاقة واحدة من النموذج فتح الرواتب ل bob مع بقاء علاقة الحجب في الجدول.
- الترقية خطوة
migrateبعد نسخة احتياطية، والمقاييس تخبرك بعدد استعلامات كل فحص، وهي أول ما تنظر إليه عندما يبطؤ المحرك.
دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.
دليلمنصة صلاحيات مركزية لخدماتك Microservicesكيف تجعل كل خدماتك تسأل نقطة قرار واحدة، وتبقي علاقاتها متزامنة مع بياناتك، وماذا يحدث عندما يتأخر سحب الصلاحية.
دليلOPA عملياً: سياسات ABAC لخدماتك وبواباتككيف تكتب سياسات ABAC بلغة Rego وتختبرها، ثم توزعها موقعة على خدماتك وبواباتك وتسجل كل قرار تتخذه.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على OpenFGA 1.21.0 ثم 1.22.0 مع fga CLI 0.8.1 وopenfga-sdk 0.10.5 وPostgreSQL 18.6 على Docker Engine 29.8.0.