سجلات تدقيق تكشف التلاعب في PostgreSQL بتسلسل الهاش
تعلم كيفية بناء سجلات تدقيق تكشف التلاعب في PostgreSQL باستخدام جداول قابلة للإضافة فقط وتسلسل الهاش حتى تصبح التعديلات سهلة الاكتشاف أثناء المراجعات والتحقيقات.

لماذا يسهل الطعن بسجلات التدقيق العادية
سجل التدقيق هو الدليل الذي تعتمده عندما يبدو أن شيئًا ما خطأ: استرداد غريب، تغيير صلاحيات لا يتذكره أحد، أو سجل عميل «اختفى». إذا كان يمكن تعديل سجل التدقيق، فيتوقف عن كونه دليلاً ويصبح مجرد بيانات يمكن لأحدهم إعادة كتابتها.
العديد من "سجلات التدقيق" هي مجرد جداول عادية. إذا أمكن تحديث الصفوف أو حذفها، يمكن تحديث القصة أو حذفها أيضًا.
فرق أساسي: منع التعديلات ليس هو نفسه جعل التعديلات قابلة للاكتشاف. يمكنك تقليل التغييرات عبر الصلاحيات، لكن أي شخص يملك وصولًا كافيًا (أو بيانات اعتماد مدير مسروقة) لا يزال قادرًا على تغيير السجل. قابلية اكتشاف التلاعب تقبل هذه الحقيقة. قد لا تمنع كل تغيير، لكن يمكنك جعل التغييرات تترك بصمة واضحة.
سجلات التدقيق العادية تُطعن لأسباب متوقعة. المستخدمون المتميزون يمكنهم "تصحيح" السجل بعد الحدث. حساب تطبيق مخترق يمكنه كتابة مدخلات مقنعة تشبه الحركة الطبيعية. يمكن ملء الطوابع الزمنية لاحقًا لإخفاء تعديل متأخر. أو قد يحذف شخص ما فقط الأسطر الأكثر ضررًا.
"قابل للاكتشاف عند التلاعب" يعني أنك تصمم سجل التدقيق بحيث حتى تعديل صغير (تغيير حقل واحد، إزالة صف، إعادة ترتيب أحداث) يصبح قابلاً للاكتشاف لاحقًا. أنت لا تعد بسحر؛ أنت تعد بأنه عندما يسأل أحدهم "كيف نعرف أن هذا السجل حقيقي؟"، يمكنك تشغيل فحوصات تظهر ما إذا تم العبث بالسجل.
قرر ما الذي تحتاج إثباته
سجل التدقيق القابل للاكتشاف مفيد فقط إذا أجاب عن الأسئلة التي ستواجهها لاحقًا: من فعل ماذا، متى فعله، وما الذي تغير.
ابدأ بالأحداث المهمة لعملك. تغييرات البيانات (إنشاء، تحديث، حذف) هي الأساس، لكن التحقيقات غالبًا ما تعتمد أيضًا على الأمن والوصول: تسجيلات الدخول، إعادة تعيين كلمات المرور، تغييرات الصلاحيات، وقفل الحسابات. إذا كنت تتعامل مع مدفوعات أو ردود أموال أو مستحقات، فاعتبر حركة الأموال أحداثًا رئيسية وليس مجرد أثر لتحديث صف.
ثم قرر ما الذي يجعل الحدث موثوقًا. عادةً يتوقع المراجعون وجود فاعل (مستخدم أو خدمة)، طابع زمني من جهة الخادم، الإجراء المتخذ، والكيان المتأثر. للتحديثات، خزّن القيم قبل وبعد (أو على الأقل الحقول الحساسة)، بالإضافة إلى request id أو correlation id حتى تتمكن من ربط تغييرات قاعدة بيانات صغيرة متعددة بفعل مستخدم واحد.
أخيرًا، كن صريحًا بشأن معنى "غير قابل للتغيير" في نظامك. القاعدة البسيطة: لا تُحدِث أو تُحذف صفوف التدقيق، فقط أدرج. إذا كان هناك خطأ، اكتب حدثًا جديدًا يصحح أو يحل محل القديم، واحتفظ بالأصل مرئيًا.
بناء جدول تدقيق قابل للإضافة فقط
احتفظ ببيانات التدقيق منفصلة عن جداولك الاعتيادية. مخطط audit مخصص يقلل الأخطاء ويجعل إدارة الصلاحيات أسهل.
الهدف بسيط: يمكن إضافة صفوف، لكن لا يمكن تغييرها أو إزالتها. في PostgreSQL، تفرض ذلك عبر الصلاحيات وبعض قواعد الأمان في تصميم الجدول.
فيما يلي جدول عملي كبداية:
CREATE SCHEMA IF NOT EXISTS audit;
CREATE TABLE audit.events (
id bigserial PRIMARY KEY,
entity_type text NOT NULL,
entity_id text NOT NULL,
event_type text NOT NULL CHECK (event_type IN ('INSERT','UPDATE','DELETE')),
actor_id text,
occurred_at timestamptz NOT NULL DEFAULT now(),
request_id text,
before_data jsonb,
after_data jsonb,
notes text
);
بعض الحقول مفيدة جدًا أثناء التحقيق:
occurred_atمعDEFAULT now()بحيث يضع الطابع الزمني من قاعدة البيانات، وليس من العميل.entity_typeوentity_idلتتمكن من تتبع سجل واحد عبر التغييرات.request_idحتى يمكن ربط إجراء مستخدم واحد بعدة صفوف.
قيده بالأدوار. يجب أن يتمكن دور التطبيق من INSERT و SELECT على audit.events، لكن ليس UPDATE أو DELETE. احتفظ بتغييرات المخطط والصلاحيات الأقوى لدور إداري لا يستخدمه التطبيق.
التقاط التغييرات عبر المشغلات (بسيطة وقابلة للتنبؤ)
إذا أردت سجل تدقيق يكشف التلاعب، فالمكان الأكثر موثوقية لالتقاط التغييرات هو قاعدة البيانات. سجلات التطبيق يمكن تجاهلها أو فلترتها أو إعادة كتابتها. المشغل ينفجر بالحدث بغض النظر عن التطبيق أو السكربت أو أداة الإدارة التي تتعامل مع الجدول.
اجعل المشغلات مملة. مهمتها يجب أن تكون شيء واحد: إضافة حدث تدقيق واحد لكل INSERT و UPDATE و DELETE على الجداول المهمة.
سجل تدقيق عملي عادةً يتضمن اسم الجدول، نوع العملية، المفتاح الأساسي، القيم قبل وبعد، طابعًا زمنيًا، ومعرفات تسمح لك بتجميع التغييرات المرتبطة (معرف المعاملة ومعرف الارتباط).
معرفات الارتباط هي الفارق بين "تحديث 20 صفًا" و "هذا كان نقرة زر واحدة". يمكن لتطبيقك وضع معرف الارتباط مرة واحدة لكل طلب (مثلاً في إعداد جلسة DB)، والمشغل يقرأه. خزّن txid_current() أيضًا، حتى تتمكن من تجميع التغييرات عندما يكون معرف الارتباط مفقودًا.
فيما يلي نمط مشغل بسيط يبقى متوقعًا لأنه يدرج فقط في جدول التدقيق (عدّل الأسماء لتطابق مخططك):
CREATE OR REPLACE FUNCTION audit_row_change() RETURNS trigger AS $$
DECLARE
corr_id text;
BEGIN
corr_id := current_setting('app.correlation_id', true);
INSERT INTO audit_events(
occurred_at, table_name, op, row_pk,
old_row, new_row, db_user, txid, correlation_id
) VALUES (
now(), TG_TABLE_NAME, TG_OP, COALESCE(NEW.id, OLD.id),
to_jsonb(OLD), to_jsonb(NEW), current_user, txid_current(), corr_id
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
قاوم الرغبة في فعل المزيد داخل المشغلات. تجنب الاستعلامات الإضافية، أو الاتصالات الشبكية، أو تفرعات معقدة. المشغلات الصغيرة أسهل للاختبار، أسرع في التشغيل، وأصعب أن تُجادل أثناء المراجعة.
أضف تسلسل هاش بحيث تترك التعديلات بصمات
جدول قابل للإضافة فقط يساعد، لكن شخصًا يملك وصولًا كافيًا لا يزال قادرًا على إعادة كتابة الصفوف الماضية. تسلسل الهاش يجعل هذا النوع من العبث مرئيًا.
أضف عمودين لكل صف تدقيقي: prev_hash و row_hash (يُسمى أحيانًا chain_hash). prev_hash يخزن هاش الصف السابق في نفس السلسلة. row_hash يخزن هاش الصف الحالي، محسوبًا من بيانات الصف زائد prev_hash.
ما الذي تحسب هاشًا له مهم. تريد إدخالًا ثابتًا وقابلًا للتكرار حتى يعطي نفس الصف نفس الهاش دومًا.
نهج عملي هو هاش سلسلة معيارية مبنية من أعمدة ثابتة (الطابع الزمني، الفاعل، الإجراء، معرف الكيان)، حمولة معيارية (غالبًا jsonb لأن المفاتيح مخزنة باستمرار)، و prev_hash.
كن حذرًا مع التفاصيل التي يمكن أن تتغير دون معنى، مثل المسافات البيضاء، ترتيب مفاتيح JSON في نص عادي، أو تنسيقات محلية. اجعل الأنواع متسقة وسلسلها بطريقة متوقعة واحدة.
سلاسل لكل تيار، لا لكل قاعدة بيانات كاملة
إذا ربطت كل حدث تدقيقي في سلسلة عالمية واحدة، قد تصبح الكتابات عنق زجاجة. كثير من الأنظمة تستخدم السلسلة داخل "تيار"، مثل لكل مستأجر، لكل نوع كيان، أو لكل كائن أعمال.
كل صف جديد يبحث عن أحدث row_hash لتياره، يخزنه كـ prev_hash، ثم يحسب row_hash الخاص به.
-- Requires pgcrypto
-- digest() returns bytea; store hashes as bytea
row_hash = digest(
concat_ws('|',
stream_key,
occurred_at::text,
actor_id::text,
action,
entity,
entity_id::text,
payload::jsonb::text,
encode(prev_hash, 'hex')
),
'sha256'
);
التقط رأس السلسلة
للمراجعات الأسرع، خزّن أحدث row_hash ("رأس السلسلة") دورياً، مثل كل يوم لكل تيار، في جدول لقطات صغير. أثناء التحقيق، يمكنك التحقق من السلسلة حتى كل لقطة بدلاً من مسح التاريخ بأكمله دفعة واحدة. اللقطات تسهل أيضًا مقارنة الصادرات والكشف عن فجوات مريبة.
التزامن والترتيب دون كسر السلسلة
تسلسل الهاش يصبح معقدًا في حركة مرور فعلية. إذا قام عمليتاْن بكتابة صفوف تدقيق في نفس الوقت وكلتاهما تستخدم نفس prev_hash، قد تحصل على تفرعات. هذا يضعف قدرتك على إثبات تسلسل واحد ونظيف.
أولاً قرر ما الذي تمثله السلسلة. سلسلة عالمية واحدة هي الأسهل في الشرح لكنها الأعلى احتكاكًا. السلاسل المتعددة تقلل الاحتكاك، لكن يجب أن تكون واضحًا بشأن ما يثبته كل تيار.
أيًا كان النموذج الذي تختاره، عرف ترتيبًا صارمًا بمعرف حدث متزايد أحادي الاتجاه (عادةً id مدعوم بتسلسل). الطوابع الزمنية ليست كافية لأنها قد تتصادم ويمكن التلاعب بها.
لتجنب حالات السباق عند حساب prev_hash، سلِّس "الحصول على آخر هاش + إدراج الصف التالي" لكل تيار. الأساليب الشائعة هي قفل صف واحد يمثل رأس التيار، أو استخدام قفل استشاري (advisory lock) مفوتر بمعرف التيار. الهدف أن كاتبيْن للتيار نفسه لا يمكن أن يقرأ كل منهما نفس آخر هاش.
التقسيم والتجزئة تؤثر على مكان وجود "آخر صف". إذا كنت تتوقع تقسيم بيانات التدقيق، اجعل كل سلسلة محتواة بالكامل داخل قسم واحد باستخدام نفس مفتاح التقسيم كمفتاح التيار (مثلاً معرف المستأجر). بهذه الطريقة تظل سلاسل المستأجر قابلة للتحقق حتى لو نُقلت المستأجرات لاحقًا عبر خوادم.
كيف تتحقق من السلسلة أثناء تحقيق
تسلسل الهاش مفيد فقط إذا استطعت إثبات أن السلسلة لا تزال سليمة عندما يُسأل عنها. النهج الأكثر أمانًا هو استعلام تحقق للقراءة فقط (أو وظيفة) يعيد حساب هاش كل صف من البيانات المخزنة ويقارنه بما هو مسجل.
مدقق بسيط يمكن تشغيله عند الطلب
يجب أن: يعيد بناء الهَاش المتوقع لكل صف، يؤكد أن كل صف يرتبط بالصف السابق، ويعلّم عن أي شيء يبدو خاطئًا.
فيما يلي نمط شائع باستخدام دوال النوافذ. عدّل أسماء الأعمدة لتطابق جدولك.
WITH ordered AS (
SELECT
id,
created_at,
actor_id,
action,
entity,
entity_id,
payload,
prev_hash,
row_hash,
LAG(row_hash) OVER (ORDER BY created_at, id) AS expected_prev_hash,
/* expected row hash, computed the same way as in your insert trigger */
encode(
digest(
coalesce(prev_hash, '') || '|' ||
id::text || '|' ||
created_at::text || '|' ||
coalesce(actor_id::text, '') || '|' ||
action || '|' ||
entity || '|' ||
entity_id::text || '|' ||
payload::text,
'sha256'
),
'hex'
) AS expected_row_hash
FROM audit_log
)
SELECT
id,
created_at,
CASE
WHEN prev_hash IS DISTINCT FROM expected_prev_hash THEN 'BROKEN_LINK'
WHEN row_hash IS DISTINCT FROM expected_row_hash THEN 'HASH_MISMATCH'
ELSE 'OK'
END AS status
FROM ordered
WHERE prev_hash IS DISTINCT FROM expected_prev_hash
OR row_hash IS DISTINCT FROM expected_row_hash
ORDER BY created_at, id;
بعيدًا عن "معطوب أم لا"، يجدر التحقق من الفجوات (معرفات مفقودة في نطاق)، روابط خارجة عن الترتيب، وتكرارات مريبة لا تتطابق مع سير العمل الفعلي.
سجّل نتائج التحقق كأحداث غير قابلة للتغيير
لا تشغّل استعلامًا وتدفنه في تذكرة. خزّن نتائج التحقق في جدول قابل للإضافة فقط منفصل (على سبيل المثال، audit_verification_runs) مع زمن التشغيل، نسخة المدقق، من أطلقه، النطاق الذي تم فحصه، وأعداد روابط مكسورة وعدم تطابق الهَاش.
هذا يمنحك أثرًا ثانيًا: ليس فقط أن سجل التدقيق سليم، بل يمكنك إظهار أنك كنت تتحقق منه.
وتيرة عملية عملية: شغّل بعد أي نشر يؤثر على منطق التدقيق، يوميًا للأنظمة النشطة، ودائمًا قبل تدقيق مخطط.
أخطاء شائعة تكسر قابلية اكتشاف التلاعب
معظم الإخفاقات ليست متعلقة بخوارزمية الهاش. هي عن الاستثناءات والفجوات التي تتيح للناس مجالًا للطعن.
أسرع طريقة لفقدان الثقة هي السماح بتحديث صفوف التدقيق. حتى لو كان "فقط هذه المرة"، فقد أنشأت سابقة ومسارًا عمليا لإعادة كتابة التاريخ. إذا احتجت لتصحيح شيء، أضف حدث تدقيقي جديد يوضح التصحيح واحتفظ بالأصل.
تسلسل الهاش يفشل أيضًا عندما تحسب هاشًا لبيانات غير ثابتة. JSON فخ شائع. إذا حسبت هاشًا لسلسلة JSON نصية، الاختلافات البسيطة (ترتيب المفاتيح، المسافات، تنسيق الأرقام) قد تغير الهَاش وتجعل التحقق مزعجًا. فضِّل شكلًا معيارياً: حقول مُطبّعة، jsonb، أو تسلسل ثابت آخر.
نمط آخر يقوّض السجل القابل للدفاع:
- هَاش الحمولة فقط وتجاهل السياق (الطابع الزمني، الفاعل، معرف الكائن، الإجراء).
- التقاط التغييرات في التطبيق فقط وافتراض أن قاعدة البيانات مطابقة دائمًا.
- استخدام دور قاعدة بيانات واحد يمكنه كتابة بيانات الأعمال وأيضًا تعديل تاريخ التدقيق.
- السماح بـ NULLs في
prev_hashداخل سلسلة دون قاعدة موثقة وواضحة.
فصل الواجبات مهم. إذا كان نفس الدور يمكنه إدراج أحداث التدقيق وأيضًا تعديلها، تصبح قابلية اكتشاف التلاعب وعدًا بدلًا من ضابط.
قائمة تحقق سريعة لسجل تدقيق يمكن الدفاع عنه
يجب أن يكون سجل التدقيق من الصعب تغييره وسهل التحقق.
ابدأ بالتحكم في الوصول: يجب أن يكون جدول التدقيق عمليًا قابلاً للإضافة فقط. دور التطبيق يجب أن يدخل (وغالبًا يقرأ)، لكن لا يحدث أو يحذف. يجب تقييد تغييرات المخطط بشدة.
تأكد أن كل صف يجيب عن الأسئلة التي سيطلبها المحقق: من فعلها، متى حدثت (من جهة الخادم)، ما الذي حدث (اسم حدث واضح بالإضافة للعملية)، ما الذي لمسته (اسم الكيان ومعرفه)، وكيف يرتبط (معرف/معرّفات الطلب والمعاملة).
ثم تحقق من طبقة التكامل. اختبار سريع هو إعادة تشغيل مقطع وتأكيد أن كل prev_hash يطابق هاش الصف السابق، وأن كل هاش مخزن يطابق الهَاش المعاد حسابه.
تشغيليًا، عامل التحقق كوظيفة اعتيادية:
- شغّل فحوصات تكامل مجدولة وخزن نتائج النجاح/الفشل والنطاقات.
- نبه عند عدم التطابق أو الفجوات أو الروابط المكسورة.
- احتفظ بنسخ احتياطية طويلة بما يكفي لتغطية نافذة الاحتفاظ، وقم بتقييد سياسات الاحتفاظ حتى لا تُنظف تاريخ التدقيق مبكرًا.
مثال: اكتشاف تعديل مريب في مراجعة امتثال
حالة اختبار شائعة هي نزاع على رد المال. يدعي عميل أنه تمت الموافقة على رد بقيمة 250$، لكن النظام الآن يظهر 25$. الدعم يصرّ أن الموافقة كانت صحيحة، والامتثال يريد إجابة.
ابدأ بتضييق البحث باستخدام معرف الارتباط (معرف الطلب، رقم التذكرة، أو refund_request_id) ونافذة زمنية. اسحب صفوف التدقيق لذلك المعرف وضعها حول زمن الموافقة.
تبحث عن مجموعة كاملة من الأحداث: إنشاء الطلب، الموافقة على رد المال، ضبط مبلغ الرد، وأي تحديثات لاحقة. مع تصميم يكشف التلاعب، تتحقق أيضًا مما إذا بقيت السلسلة سليمة.
تدفق تحقيق بسيط:
- اسحب كل صفوف التدقيق لمعرف الارتباط مرتبة زمنياً.
- أعد حساب هاش كل صف من حقوله المخزنة (بما في ذلك
prev_hash). - قارن الهَاشات المحسوبة بالهاشات المخزنة.
- حدد أول صف يختلف وانظر إن كانت الصفوف اللاحقة فشلت أيضًا.
إذا عدّل شخص صفًا واحدًا (مثل تغيير المبلغ من 250 إلى 25)، فلن يطابق هاش ذلك الصف المخزن. وبما أن الصف التالي يتضمن هاش السابق، غالبًا ما يتسبب الاختلاف في تتابع للأخطاء للأمام. هذا التتابع هو البصمة: يظهر أن السجل التّدقيقي عُدِّل بعد الحدث.
ما يمكن أن تخبرك به السلسلة: حدث تعديل، أين انكسرت السلسلة أولًا، ونطاق الصفوف المتأثرة. وما لا تستطيع قوله بمفردها: من قام بالتعديل، ما كانت القيمة الأصلية إذا استبدلت، أو ما إذا كانت جداول أخرى قد تغيرت أيضًا.
الخطوات التالية: نشر آمن والحفاظ على القابلية للصيانة
عامل سجل التدقيق كأي ضابط أمني آخر. نفّذه على دفعات صغيرة، أثبت أنه يعمل، ثم وسّع.
ابدأ بالإجراءات التي ستضرك أكثر إذا ما طعِن بها: تغييرات الصلاحيات، المدفوعات، ردود الأموال، الصادرات البيانية، والتجاوزات اليدوية. بمجرد تغطية هذه الحالات، أضف أحداثًا أقل خطورة دون تغيير التصميم الأساسي.
اكتب العقد لأحداث التدقيق: أي الحقول تُسجل، ماذا يعني كل نوع حدث، كيف يُحسب الهَاش، وكيف تُشغَّل عملية التحقق. احتفظ بهذه الوثائق بجانب ترحيلات قاعدة البيانات، واجعل إجراء التحقق قابلًا للتكرار.
اختبارات الاستعادة مهمة لأن التحقيقات غالبًا تبدأ من نسخ احتياطية، ليس النظام الحي. أعد بشكل منتظم استعادة لقاعدة اختبار وتحقق من السلسلة من الطرف إلى الطرف. إذا لم تتمكن من تكرار نفس نتيجة التحقق بعد الاستعادة، فسيكون من الصعب الدفاع عن قابلية اكتشاف التلاعب.
إذا كنت تبني أدوات داخلية وسير عمل إداري مع AppMaster (appmaster.io)، فإن توحيد كتابة أحداث التدقيق عبر عمليات خادمية متسقة يساعد في الحفاظ على مخطط الحدث ومعرفات الارتباط موحدة عبر الميزات، مما يجعل التحقق والتحقيقات أبسط بكثير.
حدد أوقات صيانة لهذا النظام. سجلات التدقيق عادةً تفشل بهدوء عندما تُطلق فرق ميزات جديدة لكنها تنسى إضافة أحداث، أو تحديث مدخلات الهاش، أو الحفاظ على وظائف التحقق واختبارات الاستعادة.


