إنتقل إلى المحتوى الرئيسي

سياسات Row Level Security (RLS)

نظرة عامة

تستخدم منصة شام باص سياسات RLS لضمان عزل البيانات على مستوى الصف. هذا يعني أن كل مستخدم يرى فقط البيانات التي يُسمح له برؤيتها.

مبادئ الأمان

1. العزل التام للشركات

كل شركة ترى فقط بياناتها الخاصة:

  • الباصات
  • المسارات
  • الرحلات
  • الحجوزات
  • الموظفين

2. عزل أدوار التشغيل

  • الراكب يرى ملفه وحجوزاته وتذاكر الدعم المرتبطة بحساب auth_user_id الخاص به فقط
  • السائق يرى ملفه وتكليفاته ومواقع GPS الخاصة به فقط عبر drivers.auth_user_id أو ربط company_user_id عند وجوده
  • المشرف النشط يستخدم سياسات إدارية مثل is_admin() للوصول إلى الموارد العابرة للشركات عند الحاجة التشغيلية

3. القراءة العامة للبيانات العامة

البيانات العامة متاحة للجميع للقراءة:

  • المدن
  • الرحلات المجدولة (للبحث)
  • المقاعد المتاحة

4. المصادقة المطلوبة للكتابة

جميع عمليات الكتابة تتطلب مصادقة.

5. انتهاء tenant التجريبي

تضيف سياسات تقييدية can_access_tenant(company_id) فوق سياسات العضوية الحالية. للعرض التجريبي يجب أن تكون الشركة نشطة وغير موقوفة وغير منتهية، وأن يحمل JWT قيمة demo_company_id المطابقة أو ترتبط هوية المستخدم بشخصية العرض. تعيد الدالة true للشركات غير التجريبية، لذلك لا تغير سلوك tenants الحقيقية.

6. صلاحيات دوال PostgREST

امتياز الصف وحده لا يكفي لدوال SECURITY DEFINER لأنها تعمل بصلاحية مالكها. لذلك تسحب التهيئة EXECUTE الافتراضي من PUBLIC وanon وauthenticated عند إنشاء الدوال، ويجب على كل ترحيل منح الدالة العامة أو المصادق عليها صراحةً.

الدالة الخادمة verify_rls_security_posture() تفشل إذا وجدت:

  • سياسة SELECT مجهولة على جدول حساس مثل bookings أو passengers
  • دالة SECURITY DEFINER متاحة لـanon خارج قائمة القراءة/مساعدات RLS المحددة
  • دالة إدارية أو cleanup أو trigger متاحة لـanon أو authenticated

لا يستطيع anon أو authenticated تشغيل verifier نفسه؛ يقتصر على service_role ويُستدعى في اختبارات التكامل وقبول النشر.

7. دليل النقل العام لا يقرأ جداول الهوية مباشرة

لا يجوز لسياسة ممنوحة لدور anon أن تتضمن subquery مباشراً إلى admin_users أو company_users أو passengers. يتطلب PostgreSQL حينها صلاحية SELECT على جدول الهوية وقد يرد بالخطأ 42501 بدل دليل المحطات.

تستخدم جداول stops وroute_stops وtrip_resource_inventory والموارد المترابطة predicates ضيقة من الترحيل 00256. الدوال SECURITY DEFINER تعيد قراراً منطقياً فقط، مع search_path ثابت ومنح EXECUTE للأدوار المطلوبة فقط. يثبت public-connected-transport-directory.sql أن:

  • الزائر يرى المحطة النشطة ولا يرى المحطة غير النشطة.
  • البحث العام يقرأ نمط محطات المسار ومخزون المقاعد.
  • سياسات النقل المترابط لا تحتوي إحالات مباشرة لجداول الهوية.

يعيد الترحيل 00257 أيضاً سحب EXECUTE المباشر من جميع trigger functions لدى anon وauthenticated. يقارن verifier دوال SECURITY DEFINER المتاحة للزائر بقائمة تواقيع REGPROCEDURE دقيقة، ليس بأسماء دوال عامة قد تطابق overload آخر. أي RPC جديد مفوض ومجهول يفشل بوابة الأمان حتى يضاف توقيعه صراحةً.


سياسات الجداول الأساسية

companies - شركات النقل

-- القراءة: الشركات النشطة متاحة للجميع
CREATE POLICY "companies_select_public" ON companies
FOR SELECT USING (is_active = true AND is_verified = true);

-- التعديل: فقط مستخدمي الشركة
CREATE POLICY "companies_update_own" ON companies
FOR UPDATE USING (
id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
AND role IN ('OWNER', 'ADMIN')
)
);

العروض التجريبية لا تدخل القراءة العامة حتى لو كانت الشركة نشطة؛ رحلة العرض تتطلب سياق capability/JWT مطابقاً. كما تمنع سياسات Operational tenant required القراءة والكتابة على الجداول التابعة فور انتهاء العرض، قبل تنفيذ الحذف الدوري.

جداول التحكم بالعروض

الجداول demo_access_grants وdemo_personas وdemo_outbox مفعّل عليها RLS ولا تمنح أي وصول لـanon أو authenticated. إدارتها محصورة في service_role، بينما دوال الإنشاء والتمديد وإعادة الضبط والتدوير والإلغاء والحصاد مسحوبة من PUBLIC وممنوحة لـservice_role فقط.

لا تستخدم service-role داخل المتصفح. يمر الوصول عبر API خادمي يتحقق من الصلاحية الإدارية، rate limit، بصمة الرمز، ووقت انتهاء الشركة.


routes - المسارات

-- القراءة: المسارات النشطة للشركات النشطة
CREATE POLICY "routes_select_public" ON routes
FOR SELECT USING (
is_active = true
AND company_id IN (
SELECT id FROM companies WHERE is_active = true
)
);

-- الإدارة: فقط مستخدمي الشركة
CREATE POLICY "routes_manage_own" ON routes
FOR ALL USING (
company_id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
)
);

buses - الباصات

-- القراءة: للشركة المالكة فقط
CREATE POLICY "buses_select_company" ON buses
FOR SELECT USING (
company_id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
)
);

-- الإدارة: فقط مستخدمي الشركة
CREATE POLICY "buses_manage_company" ON buses
FOR ALL USING (
company_id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
)
);

trips - الرحلات

-- القراءة: الرحلات المجدولة للجميع
CREATE POLICY "trips_select_public" ON trips
FOR SELECT USING (
status IN ('SCHEDULED', 'BOARDING')
AND departure_time > NOW()
);

-- الإدارة: فقط للشركة المالكة
CREATE POLICY "trips_manage_company" ON trips
FOR ALL USING (
route_id IN (
SELECT r.id FROM routes r
JOIN company_users cu ON cu.company_id = r.company_id
WHERE cu.auth_user_id = auth.uid()
)
);

bookings - الحجوزات

-- القراءة: الراكب يرى حجوزاته
CREATE POLICY "bookings_select_passenger" ON bookings
FOR SELECT USING (
passenger_id IN (
SELECT id FROM passengers
WHERE auth_user_id = auth.uid()
)
);

-- القراءة: الشركة ترى حجوزات رحلاتها
CREATE POLICY "bookings_select_company" ON bookings
FOR SELECT USING (
trip_id IN (
SELECT t.id FROM trips t
JOIN routes r ON t.route_id = r.id
JOIN company_users cu ON cu.company_id = r.company_id
WHERE cu.auth_user_id = auth.uid()
)
);

-- الإنشاء: المستخدم المصادق عليه
CREATE POLICY "bookings_insert_authenticated" ON bookings
FOR INSERT WITH CHECK (
auth.uid() IS NOT NULL
);

-- التعديل: الراكب يعدل حجوزاته
CREATE POLICY "bookings_update_passenger" ON bookings
FOR UPDATE USING (
passenger_id IN (
SELECT id FROM passengers
WHERE auth_user_id = auth.uid()
)
);

لا يقرأ الزائر المجهول bookings أو bus_locations مباشرة. رابط التتبع يستدعي get_public_trip_tracking(booking_code) بكود مطابق لصيغة SB-XXXXXX. الدالة SECURITY DEFINER ذات search_path ثابت، وتعيد فقط المسار والشركة وحالة التكليف وآخر GPS/سجل محدود. لا تعيد اسم الراكب أو هاتفه أو المقعد أو الدفع أو الملاحظات أو هوية السائق، كما تحجب GPS خارج نافذة الرحلة وعن الحجوزات والرحلات الملغاة.

تعمل معاينة قسيمة الولاء واستهلاكها وإنشاء الحجز عبر دوال خادمة مقيدة بـ service_role. لا يستطيع عميل PostgREST تمرير معرف راكب آخر إلى هذه الدوال؛ تحل مسارات Next.js مالك الولاء من جلسة العميل، ثم تتحقق الدالة من ملكية القسيمة وتقفلها قبل تعديل الحجز.


passengers - الركاب

-- القراءة: الراكب يرى ملفه الشخصي
CREATE POLICY "passengers_select_own" ON passengers
FOR SELECT USING (auth_user_id = auth.uid());

-- التعديل: الراكب يعدل ملفه الشخصي
CREATE POLICY "passengers_update_own" ON passengers
FOR UPDATE USING (auth_user_id = auth.uid());

-- الإنشاء: المستخدم الجديد
CREATE POLICY "passengers_insert_new" ON passengers
FOR INSERT WITH CHECK (
auth_user_id = auth.uid()
OR auth_user_id IS NULL
);

جداول الولاء

  • company_loyalty_programs: يقرأ المسافر البرامج المنشورة، بينما يدير OWNER وADMIN صف شركتهما فقط.
  • loyalty_tiers وloyalty_rewards: القراءة العامة تقتصر على الصفوف النشطة غير التاريخية التابعة لبرنامج شركة منشور. إدارة الشركة مقيدة بـcompany_id.
  • loyalty_accounts: يرى الراكب حساباته المرتبطة بـ passengers.auth_user_id = auth.uid() فقط، مع حساب منفصل لكل شركة.
  • loyalty_transactions وloyalty_redemptions: يرى الراكب السجلات التابعة لحسابه وفي الشركة المطابقة فقط.
  • loyalty_referrals: يرى الراكب الإحالات التي يكون فيها المُحيل أو المُحال ضمن الشركة نفسها.
  • الصفوف التاريخية ذات is_legacy_platform=true لا تظهر كرصيد شركة ولا تقبل عمليات الشركة الجديدة.
  • الكتابة المباشرة من authenticated غير معتمدة للاستبدال أو تطبيق الخصم؛ تمر الكتابة عبر APIs الخادم والعقود الذرية.

الدوال add_company_loyalty_points وredeem_company_loyalty_reward و apply_company_loyalty_referral وinspect_customer_loyalty_redemption و preview_customer_loyalty_redemption ونسختا إنشاء حجز العميل مسحوبة من PUBLIC وanon وauthenticated وممنوحة لـservice_role فقط. لا تضف لها grant عميل عند إنشاء تكامل جديد.

كما لا تمنح عملية تهيئة قاعدة البيانات صلاحية تنفيذ جماعية على كل دوال public إلى service_role. تظل صلاحيات الدوال مملوكة للمهاجرات، ويعيد 00262_retire_platform_loyalty_rpc_acl.sql سحب صلاحيات عقود الولاء العامة القديمة من البيئات التي أعادت منحها عملية تهيئة سابقة.


seats - المقاعد

-- القراءة: المقاعد متاحة للجميع
CREATE POLICY "seats_select_public" ON seats
FOR SELECT USING (true);

-- التعديل: الشركة المالكة للرحلة
CREATE POLICY "seats_manage_company" ON seats
FOR ALL USING (
trip_id IN (
SELECT t.id FROM trips t
JOIN routes r ON t.route_id = r.id
JOIN company_users cu ON cu.company_id = r.company_id
WHERE cu.auth_user_id = auth.uid()
)
);

-- الحجز: المستخدم المصادق
CREATE POLICY "seats_book_authenticated" ON seats
FOR UPDATE USING (
status = 'AVAILABLE' OR booking_id IS NULL
) WITH CHECK (true);

trip_reviews - مراجعات الرحلات

  • ينشئ الراكب مراجعة منشورة فقط عندما تتطابق هوية الحجز والراكب والرحلة والشركة، ويكون الحجز مكتملاً أو تكون الرحلة قد وصلت فعلياً.
  • يستطيع الراكب تعديل أو حذف مراجعته فقط. يمنع trigger تغيير booking_id أو passenger_id أو trip_id أو company_id، كما يمنعه من تحويل حالة الإشراف إلى قيمة غير PUBLISHED.
  • يرى مستخدم الشركة النشط مراجعات شركته، لكنه لا يملك سياسة عامة لإنشاء مراجعة باسم عميل أو تغيير حالة الإشراف مباشرة.
  • تبقى عمليات الإشراف الموثوقة في مسارات الخادم ذات الصلاحية المناسبة.

هذه القيود تمنع إرسال مراجعة تحمل معرف حجز أو راكب آخر حتى لو عرف المستخدم معرف الرحلة.


سياسات جداول المستخدمين

company_users - مستخدمو الشركات

-- القراءة: مستخدمو نفس الشركة
CREATE POLICY "company_users_select_same" ON company_users
FOR SELECT USING (
company_id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
)
);

-- الإدارة: المالك والمدير فقط
CREATE POLICY "company_users_manage" ON company_users
FOR ALL USING (
company_id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
AND role IN ('OWNER', 'ADMIN')
)
);

admin_users - المشرفون

-- القراءة: المشرفون فقط
CREATE POLICY "admin_users_select_admins" ON admin_users
FOR SELECT USING (
auth.uid() IN (
SELECT auth_user_id FROM admin_users WHERE is_active = true
)
);

-- الإدارة: SUPER_ADMIN فقط
CREATE POLICY "admin_users_manage_super" ON admin_users
FOR ALL USING (
auth.uid() IN (
SELECT auth_user_id FROM admin_users
WHERE role = 'SUPER_ADMIN' AND is_active = true
)
);

drivers - السائقون

-- القراءة: السائق يرى ملفه + الشركة ترى سائقيها
CREATE POLICY "drivers_select" ON drivers
FOR SELECT USING (
company_user_id IN (
SELECT id FROM company_users WHERE auth_user_id = auth.uid()
)
OR company_id IN (
SELECT company_id FROM company_users WHERE auth_user_id = auth.uid()
)
);

-- الإدارة: الشركة تدير سائقيها
CREATE POLICY "drivers_manage_company" ON drivers
FOR ALL USING (
company_id IN (
SELECT company_id FROM company_users
WHERE auth_user_id = auth.uid()
AND role IN ('OWNER', 'ADMIN')
)
);

سياسات جداول تتبع السائق

trip_assignments - تعيينات الرحلات

-- القراءة: السائق والشركة
CREATE POLICY "trip_assignments_select" ON trip_assignments
FOR SELECT USING (
driver_id IN (
SELECT d.id FROM drivers d
JOIN company_users cu ON d.company_user_id = cu.id
WHERE cu.auth_user_id = auth.uid()
)
OR company_id IN (
SELECT company_id FROM company_users WHERE auth_user_id = auth.uid()
)
);

-- التعديل: السائق المعين
CREATE POLICY "trip_assignments_update_driver" ON trip_assignments
FOR UPDATE USING (
driver_id IN (
SELECT d.id FROM drivers d
JOIN company_users cu ON d.company_user_id = cu.id
WHERE cu.auth_user_id = auth.uid()
)
);

bus_locations - مواقع الباصات

-- الإدخال: السائق فقط
CREATE POLICY "bus_locations_insert_driver" ON bus_locations
FOR INSERT WITH CHECK (
driver_id IN (
SELECT d.id FROM drivers d
JOIN company_users cu ON d.company_user_id = cu.id
WHERE cu.auth_user_id = auth.uid()
)
);

-- القراءة: الشركة والراكب المحجوز
CREATE POLICY "bus_locations_select" ON bus_locations
FOR SELECT USING (
-- الشركة
trip_id IN (
SELECT t.id FROM trips t
JOIN routes r ON t.route_id = r.id
JOIN company_users cu ON cu.company_id = r.company_id
WHERE cu.auth_user_id = auth.uid()
)
-- أو الراكب المحجوز
OR trip_id IN (
SELECT b.trip_id FROM bookings b
JOIN passengers p ON b.passenger_id = p.id
WHERE p.auth_user_id = auth.uid()
AND b.status = 'CONFIRMED'
)
);

passenger_checkins - تسجيل الصعود

-- الإدخال: السائق
CREATE POLICY "checkins_insert_driver" ON passenger_checkins
FOR INSERT WITH CHECK (
driver_id IN (
SELECT d.id FROM drivers d
JOIN company_users cu ON d.company_user_id = cu.id
WHERE cu.auth_user_id = auth.uid()
)
);

-- القراءة: الشركة والراكب
CREATE POLICY "checkins_select" ON passenger_checkins
FOR SELECT USING (
passenger_id IN (
SELECT id FROM passengers WHERE auth_user_id = auth.uid()
)
OR trip_id IN (
SELECT t.id FROM trips t
JOIN routes r ON t.route_id = r.id
JOIN company_users cu ON cu.company_id = r.company_id
WHERE cu.auth_user_id = auth.uid()
)
);

سياسات جداول الدعم الفني

support_tickets - تذاكر الدعم

-- القراءة: صاحب التذكرة أو المشرف
CREATE POLICY "tickets_select" ON support_tickets
FOR SELECT USING (
-- الراكب
passenger_id IN (
SELECT id FROM passengers WHERE auth_user_id = auth.uid()
)
-- أو الشركة
OR company_id IN (
SELECT company_id FROM company_users WHERE auth_user_id = auth.uid()
)
-- أو المشرف
OR auth.uid() IN (
SELECT auth_user_id FROM admin_users WHERE is_active = true
)
);

-- الإنشاء: أي مستخدم مصادق
CREATE POLICY "tickets_insert" ON support_tickets
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);

-- التعديل: المشرف المسند إليه
CREATE POLICY "tickets_update_admin" ON support_tickets
FOR UPDATE USING (
assigned_to IN (
SELECT id FROM admin_users WHERE auth_user_id = auth.uid()
)
OR auth.uid() IN (
SELECT auth_user_id FROM admin_users
WHERE role = 'SUPER_ADMIN' AND is_active = true
)
);

سياسات Service Role

بعض العمليات تتطلب صلاحيات كاملة:

-- الوصول الكامل لـ service_role
CREATE POLICY "service_role_full_access" ON [table_name]
FOR ALL TO service_role
USING (true) WITH CHECK (true);

الجداول التي تستخدم service_role:

  • admin_audit_log - للتسجيل
  • audit_log - سجل تدقيق عام ملحق فقط؛ لا وصول مباشر من عملاء الويب أو الموبايل
  • notifications - للإرسال
  • whatsapp_message_queue - للإرسال
  • bus_templates - للإدارة
  • waitlist - لإدخال وقراءة طلبات قائمة الانتظار من الخدمات الخلفية

waitlist يسمح أيضاً بإدخال عام فقط عبر سياسة insert، بينما القراءة والتعديل محصوران بالمشرفين النشطين أو service_role.


دوال المساعدة

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

CREATE OR REPLACE FUNCTION is_company_member(p_company_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM company_users
WHERE company_id = p_company_id
AND auth_user_id = auth.uid()
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

التحقق من صلاحية المشرف

CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM admin_users
WHERE auth_user_id = auth.uid()
AND is_active = true
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

التحقق من ملكية الحجز

CREATE OR REPLACE FUNCTION owns_booking(p_booking_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM bookings b
JOIN passengers p ON b.passenger_id = p.id
WHERE b.id = p_booking_id
AND p.auth_user_id = auth.uid()
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

نصائح الأداء

1. استخدام الفهارس

-- فهرس على auth_user_id للبحث السريع
CREATE INDEX idx_company_users_auth ON company_users(auth_user_id);
CREATE INDEX idx_passengers_auth ON passengers(auth_user_id);
CREATE INDEX idx_admin_users_auth ON admin_users(auth_user_id);

2. تجنب الاستعلامات الفرعية المتكررة

استخدم دوال SECURITY DEFINER للتحقق المتكرر.

3. تقييد البيانات المسترجعة

استخدم LIMIT و pagination في الاستعلامات.


اختبار السياسات

-- اختبار كمستخدم محدد
SET request.jwt.claims = '{"sub": "user-uuid"}';

-- اختبار الاستعلام
SELECT * FROM bookings;

-- إعادة التعيين
RESET request.jwt.claims;

استكشاف الأخطاء

خطأ: "Row level security policy violation"

الأسباب المحتملة:

  1. المستخدم غير مصادق
  2. المستخدم لا يملك الصلاحية
  3. البيانات لا تطابق السياسة

الحل:

  1. تحقق من auth.uid()
  2. تحقق من ملكية البيانات
  3. راجع سياسة RLS للجدول
-- عرض السياسات
SELECT * FROM pg_policies WHERE tablename = 'bookings';