29 thg 8, 2025·7 phút đọc

Audit trail phát hiện sửa đổi trong PostgreSQL bằng hash chaining

Tìm hiểu cách tạo audit trail phát hiện sửa đổi trong PostgreSQL bằng bảng chỉ-ghi và hash chaining, để các chỉnh sửa dễ phát hiện khi rà soát và điều tra.

Audit trail phát hiện sửa đổi trong PostgreSQL bằng hash chaining

Tại sao audit log thông thường dễ bị tranh cãi

Audit trail là hồ sơ bạn dựa vào khi có điều gì đó bất thường: hoàn tiền lạ, thay đổi quyền mà không ai nhớ, hoặc bản ghi khách hàng “biến mất”. Nếu audit trail có thể bị sửa, nó không còn là bằng chứng mà chỉ trở thành một dữ liệu khác mà ai đó có thể ghi đè.

Nhiều “audit log” thực ra là các bảng bình thường. Nếu hàng có thể được cập nhật hoặc xóa, thì câu chuyện cũng có thể bị cập nhật hoặc xóa.

Một khác biệt quan trọng: ngăn chặn chỉnh sửa không giống với làm cho chỉnh sửa dễ phát hiện. Bạn có thể giảm số thay đổi bằng quyền, nhưng bất cứ ai có đủ quyền (hoặc credential admin bị đánh cắp) vẫn có thể thay đổi lịch sử. Tamper-evidence chấp nhận thực tế đó. Bạn có thể không ngăn được mọi thay đổi, nhưng bạn có thể làm cho mỗi thay đổi để lại dấu vết rõ ràng.

Audit log thường bị tranh cãi vì những lý do dễ đoán. Người dùng có đặc quyền có thể “sửa” log sau khi sự việc xảy ra. Một tài khoản ứng dụng bị xâm nhập có thể ghi các mục hợp lý trông như lưu lượng bình thường. Timestamp có thể bị điền ngược để che thay đổi muộn. Hoặc ai đó chỉ xóa những dòng gây hại nhất.

“Tamper-evident” nghĩa là bạn thiết kế audit trail sao cho ngay cả một chỉnh sửa nhỏ (thay một trường, xóa một hàng, thay đổi thứ tự sự kiện) cũng có thể bị phát hiện sau này. Bạn không hứa phép màu. Bạn hứa rằng khi ai đó hỏi “Làm sao chúng ta biết log này thật?”, bạn có thể chạy các kiểm tra để thấy liệu log có bị chạm tới hay không.

Xác định bạn cần chứng minh gì

Một audit trail có khả năng phát hiện sửa đổi chỉ hữu ích nếu nó trả lời được các câu hỏi bạn sẽ gặp sau này: ai làm gì, khi nào họ làm, và gì đã thay đổi.

Bắt đầu với những sự kiện quan trọng đối với doanh nghiệp. Thay đổi dữ liệu (tạo, cập nhật, xóa) là nền tảng, nhưng điều tra thường phụ thuộc cả vào bảo mật và truy cập: đăng nhập, đặt lại mật khẩu, thay đổi quyền, và khóa tài khoản. Nếu bạn xử lý thanh toán, hoàn tiền, credit hoặc payout, hãy coi chuyển tiền là sự kiện hàng đầu, không chỉ là hệ quả của một hàng bị cập nhật.

Rồi quyết định điều gì làm cho một sự kiện có độ tin cậy. Kiểm toán viên thường mong thấy một actor (người dùng hoặc service), timestamp phía server, hành động đã thực hiện, và đối tượng bị ảnh hưởng. Với cập nhật, lưu giá trị trước và sau (hoặc ít nhất các trường nhạy cảm), cộng với request id hoặc correlation id để bạn có thể liên kết nhiều thay đổi nhỏ trong DB về một hành động của người dùng.

Cuối cùng, rõ ràng về ý nghĩa “bất biến” trong hệ thống của bạn. Quy tắc đơn giản nhất: không bao giờ cập nhật hoặc xóa hàng audit, chỉ insert. Nếu có gì đó sai, ghi một sự kiện mới để sửa hoặc ghi đè, và giữ bản gốc hiển thị.

Xây bảng audit chỉ-ghi (append-only)

Giữ dữ liệu audit tách riêng khỏi bảng bình thường. Một schema audit riêng giúp giảm chỉnh sửa vô ý và làm quyền dễ quản lý hơn.

Mục tiêu đơn giản: chỉ được thêm hàng, không được thay đổi hay xóa. Trong PostgreSQL, bạn thực thi điều đó bằng quyền (ai có thể làm gì) và một vài biện pháp an toàn trong thiết kế bảng.

Đây là một bảng khởi điểm thực tế:

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
);

Một vài trường đặc biệt hữu ích khi điều tra:

  • occurred_at với DEFAULT now() để thời gian được đóng dấu bởi cơ sở dữ liệu, không phải client.
  • entity_typeentity_id để bạn có thể theo dõi một bản ghi qua các thay đổi.
  • request_id để một hành động người dùng có thể được truy vết qua nhiều hàng.

Khóa nó bằng vai trò. Role ứng dụng nên chỉ có quyền INSERTSELECT trên audit.events, không được UPDATE hoặc DELETE. Giữ thay đổi schema và quyền mạnh hơn cho một role admin không được dùng bởi app.

Bắt sự thay đổi bằng trigger (gọn và dễ dự đoán)

Nếu bạn muốn audit trail có khả năng phát hiện sửa đổi, nơi đáng tin cậy nhất để bắt thay đổi là trong cơ sở dữ liệu. Log ở ứng dụng có thể bị bỏ qua, lọc, hoặc ghi đè. Trigger chạy bất kể app, script, hay công cụ admin nào chạm tới bảng.

Giữ trigger đơn giản. Nhiệm vụ của nó chỉ một: thêm một event audit cho mỗi INSERT, UPDATE, DELETE trên các bảng quan trọng.

Một bản ghi audit thực tế thường bao gồm tên bảng, loại thao tác, khóa chính, giá trị trước và sau, timestamp, và các identifier cho phép bạn nhóm các thay đổi liên quan (transaction id và correlation id).

Correlation id là khác biệt giữa “20 hàng được cập nhật” và “Đây là một lần nhấn nút”. App của bạn có thể đặt correlation id cho mỗi request (ví dụ, trong một setting session DB), và trigger có thể đọc nó. Lưu cả txid_current() nữa, để bạn vẫn có thể nhóm các thay đổi khi correlation id thiếu.

Đây là mẫu trigger đơn giản giữ tính dự đoán vì nó chỉ insert vào bảng audit (điều chỉnh tên cho phù hợp schema của bạn):

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;

Cự tuyệt ham muốn làm nhiều hơn trong trigger. Tránh các truy vấn bổ sung, gọi mạng, hoặc phân nhánh phức tạp. Trigger nhỏ dễ test, chạy nhanh hơn, và khó bị phản biện trong quá trình xem xét.

Thêm hash chaining để mọi chỉnh sửa để lại dấu vết

Phát hành panel quản trị tuân thủ
Xây panel quản trị an toàn với truy cập theo vai trò và lịch sử sự kiện chỉ cho phép ghi cho các bảng nhạy cảm.
Tạo ứng dụng

Bảng chỉ-ghi giúp, nhưng ai đó có đủ quyền vẫn có thể ghi đè các hàng cũ. Hash chaining làm cho dạng thao tác đó dễ thấy.

Thêm hai cột vào mỗi hàng audit: prev_hashrow_hash (đôi khi gọi là chain_hash). prev_hash lưu hash của hàng trước cùng chuỗi. row_hash lưu hash của hàng hiện tại, được tính từ dữ liệu hàng cộng prev_hash.

Cái bạn băm rất quan trọng. Bạn cần một input ổn định, có thể lặp lại để cùng một hàng luôn cho ra cùng một hash.

Một cách tiếp cận thực tế là băm một chuỗi chuẩn hóa (canonical) được xây từ các cột cố định (timestamp, actor, action, entity id), một payload chuẩn (thường là jsonb, vì khóa lưu trữ nhất quán), và prev_hash.

Cẩn thận với các chi tiết có thể thay đổi mà không có ý nghĩa, như khoảng trắng, thứ tự khóa JSON ở dạng văn bản, hoặc định dạng phụ thuộc locale. Giữ kiểu dữ liệu nhất quán và serialize theo một cách dự đoán duy nhất.

Chuỗi theo luồng (stream), không theo toàn DB

Nếu bạn nối chuỗi mọi sự kiện audit vào một dãy toàn cục, ghi có thể trở thành cổ chai. Nhiều hệ thống nối trong một “stream”, ví dụ theo tenant, theo loại entity, hoặc theo đối tượng nghiệp vụ.

Mỗi hàng mới tra cứu row_hash mới nhất cho stream của nó, lưu nó vào prev_hash, rồi tính row_hash của chính nó.

-- 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'
);

Lấy snapshot đầu chuỗi

Để rà soát nhanh hơn, lưu row_hash mới nhất (“chain head”) định kỳ, ví dụ hàng ngày cho mỗi stream, vào một bảng snapshot nhỏ. Khi điều tra, bạn có thể xác minh chuỗi đến từng snapshot thay vì quét toàn bộ lịch sử cùng lúc. Snapshot cũng giúp so sánh xuất khẩu và phát hiện khoảng trống đáng ngờ dễ hơn.

Đồng thời và thứ tự mà không làm vỡ chuỗi

Hash chaining phức tạp trong lưu lượng thực. Nếu hai transaction ghi hàng audit cùng lúc và cả hai đều dùng cùng prev_hash, bạn có thể có phân nhánh (fork). Điều đó làm mờ khả năng chứng minh một chuỗi tuần tự đơn.

Trước tiên quyết định chuỗi của bạn đại diện cho gì. Một chuỗi toàn cục dễ giải thích nhất nhưng có độ tranh chấp cao. Nhiều chuỗi giảm tranh chấp, nhưng bạn phải rõ ràng chuỗi nào chứng minh điều gì.

Dù chọn mô hình nào, định nghĩa thứ tự nghiêm ngặt bằng một id sự kiện tăng dần (thường là id backed by sequence). Timestamp không đủ vì có thể trùng và có thể bị thao tác.

Để tránh race khi tính prev_hash, serialize “lấy hash cuối + insert hàng tiếp” cho mỗi stream. Các cách phổ biến là khóa một hàng duy nhất đại diện cho đầu chuỗi, hoặc dùng advisory lock dựa trên stream id. Mục tiêu là hai writer cùng stream không thể cùng đọc được last hash giống nhau.

Partitioning và sharding ảnh hưởng nơi “hàng cuối cùng” nằm. Nếu bạn dự định partition dữ liệu audit, giữ mỗi chain nằm hoàn toàn trong một partition bằng cách dùng cùng partition key như stream key (ví dụ tenant id). Bằng vậy, chuỗi tenant vẫn có thể xác minh được ngay cả khi tenant sau này di chuyển qua server khác.

Cách xác minh chuỗi khi điều tra

Sở hữu triển khai audit của bạn
Muốn kiểm soát hoàn toàn sau này? Xuất source code và giữ logic audit minh bạch, có thể xem xét.
Xuất mã

Hash chaining chỉ có ích nếu bạn có thể chứng minh chuỗi vẫn nguyên khi ai đó hỏi. Cách an toàn nhất là một truy vấn xác minh chỉ đọc (hoặc job) recompute hash cho từng hàng từ dữ liệu lưu và so sánh với giá trị ghi.

Một verifier đơn giản chạy theo yêu cầu

Một verifier nên: xây lại hash mong đợi cho mỗi hàng, xác nhận mỗi hàng liên kết với hàng trước, và đánh dấu điều gì đó bất thường.

Đây là mẫu dùng window functions. Điều chỉnh tên cột cho khớp bảng của bạn.

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;

Ngoài “bị vỡ hay không,” cũng nên kiểm tra khoảng trống (missing ids trong một dải), liên kết sai thứ tự, và bản sao đáng ngờ không khớp với luồng công việc thực tế.

Ghi kết quả xác minh thành sự kiện bất biến

Đừng chạy truy vấn rồi chôn kết quả trong ticket. Lưu kết quả xác minh vào một bảng append-only riêng (ví dụ audit_verification_runs) với thời gian chạy, phiên bản verifier, người kích hoạt, dải kiểm tra, và các đếm lỗi broken link và hash mismatch.

Điều đó cho bạn một đường chứng thứ hai: không chỉ audit log còn nguyên, bạn còn có thể chứng minh bạn đã kiểm tra nó.

Một nhịp thực tế: chạy sau mỗi deploy ảnh hưởng logic audit, hàng đêm cho hệ thống hoạt động, và luôn trước một cuộc kiểm toán theo kế hoạch.

Những sai lầm phổ biến phá hỏng khả năng phát hiện sửa đổi

Xây các lượt xác minh chuỗi
Tạo workflow xác minh recompute hash và lưu kết quả kiểm tra tính toàn vẹn dưới dạng sự kiện.
Bắt đầu dự án

Hầu hết thất bại không phải do thuật toán băm. Là do ngoại lệ và khoảng trống tạo cơ hội cho tranh luận.

Cách nhanh nhất để mất niềm tin là cho phép cập nhật hàng audit. Dù chỉ là “chỉ lần này”, bạn đã tạo tiền lệ và con đường để viết lại lịch sử. Nếu cần sửa, hãy thêm một event audit mới giải thích sửa và giữ bản gốc.

Hash chaining cũng hỏng khi bạn băm dữ liệu không ổn định. JSON là bẫy phổ biến. Nếu bạn băm một chuỗi JSON, những khác biệt vô hại (thứ tự khóa, khoảng trắng, định dạng số) có thể thay đổi hash và làm cho xác minh ồn ào. Ưu tiên dạng chuẩn hóa: trường đã chuẩn hóa, jsonb, hoặc một serialization nhất quán khác.

Các mẫu khác làm suy yếu trail đáng tin cậy:

  • Chỉ băm payload và bỏ qua ngữ cảnh (timestamp, actor, object id, action).
  • Chỉ ghi thay đổi ở ứng dụng và cho rằng DB sẽ luôn khớp.
  • Dùng một role DB có thể vừa ghi dữ liệu nghiệp vụ vừa sửa lịch sử audit.
  • Cho phép NULL cho prev_hash trong chuỗi mà không có quy tắc rõ ràng, được tài liệu.

Phân chia nhiệm vụ quan trọng. Nếu cùng một role có thể insert event audit và cũng sửa chúng, tamper-evidence biến từ một lời hứa thành điều không kiểm soát được.

Checklist nhanh cho audit trail có thể bảo vệ được

Một audit trail bảo vệ được nên khó thay đổi và dễ xác minh.

Bắt đầu với kiểm soát truy cập: bảng audit phải thực tế chỉ cho phép ghi. Role ứng dụng nên insert (và thường read), nhưng không update hay delete. Thay đổi schema nên hạn chế chặt.

Đảm bảo mỗi hàng trả lời câu hỏi điều tra sẽ hỏi: ai làm, khi nào (phía server), chuyện gì đã xảy ra (tên sự kiện rõ ràng + thao tác), đối tượng nào bị tác động (tên entity và id), và cách nó kết nối (request/correlation id và transaction id).

Rồi kiểm thử lớp tính toàn vẹn. Kiểm tra nhanh là replay một đoạn và xác nhận mỗi prev_hash khớp hash hàng trước, và mỗi hash lưu trữ khớp hash tính lại.

Về vận hành, coi xác minh như một job bình thường:

  • Chạy kiểm tra định kỳ và lưu kết quả pass/fail cùng dải kiểm tra.
  • Cảnh báo khi có mismatch, khoảng trống, hoặc liên kết bị vỡ.
  • Giữ backup đủ lâu để phủ window retention, và khóa cơ chế retention để lịch sử audit không bị “dọn sớm”.

Ví dụ: phát hiện chỉnh sửa đáng ngờ trong cuộc rà soát tuân thủ

Tập trung hóa logging audit
Tạo các service backend Go ghi log thay đổi dữ liệu từ một nơi, thay vì log rải rác trong app.
Xây backend

Một kịch bản kiểm tra phổ biến là tranh chấp hoàn tiền. Khách hàng nói họ được duyệt hoàn $250, nhưng hệ thống giờ hiển thị $25. Hỗ trợ khăng khăng việc duyệt là đúng, và compliance muốn câu trả lời.

Bắt đầu bằng thu hẹp tìm kiếm dùng correlation id (order id, ticket id, hoặc refund_request_id) và một cửa sổ thời gian. Lấy các hàng audit cho correlation id đó và bao quanh chúng quanh thời điểm duyệt.

Bạn tìm toàn bộ tập sự kiện: yêu cầu tạo, hoàn tiền được duyệt, số tiền hoàn được đặt, và bất kỳ cập nhật sau đó. Với thiết kế tamper-evident, bạn cũng kiểm tra chuỗi có giữ nguyên hay không.

Một luồng điều tra đơn giản:

  • Lấy tất cả hàng audit cho correlation id theo thứ tự thời gian.
  • Tính lại hash mỗi hàng từ các trường đã lưu (bao gồm prev_hash).
  • So sánh hash tính được với hash lưu.
  • Xác định hàng đầu tiên khác nhau và xem các hàng sau có lỗi nữa không.

Nếu ai đó sửa một hàng audit (ví dụ thay amount từ 250 thành 25), hash của hàng đó sẽ không khớp. Vì hàng tiếp theo chứa hash của hàng trước, sự không khớp thường lan sang các hàng sau. Chuỗi lan truyền này là dấu vết: nó cho thấy bản ghi audit bị sửa sau khi sự việc xảy ra.

Chuỗi có thể cho bạn biết: có chỉnh sửa đã xảy ra, nơi chuỗi lần đầu bị gãy, và phạm vi hàng bị ảnh hưởng. Chuỗi không thể tự nó cho bạn biết: ai đã chỉnh sửa, giá trị gốc là gì nếu đã bị ghi đè, hoặc liệu các bảng khác có bị thay đổi không.

Bước tiếp theo: triển khai an toàn và giữ cho hệ thống dễ duy trì

Đối xử với audit trail như một control bảo mật khác. Triển khai theo từng bước nhỏ, chứng minh nó hoạt động, rồi mở rộng.

Bắt đầu với các hành động gây rủi ro lớn nhất nếu bị tranh chấp: thay đổi quyền, payout, refund, xuất dữ liệu, và ghi đè thủ công. Khi các phần đó đã được che phủ, thêm các sự kiện rủi ro thấp hơn mà không đổi thiết kế cốt lõi.

Ghi lại hợp đồng cho các event audit: trường nào được lưu, mỗi loại event nghĩa là gì, cách tính hash, và cách chạy xác minh. Giữ tài liệu đó gần migrations DB, và làm cho thủ tục xác minh có thể lặp lại.

Bài tập khôi phục quan trọng vì điều tra thường bắt đầu từ backup, không phải hệ thống live. Thường xuyên restore vào DB test và xác minh chuỗi từ đầu đến cuối. Nếu bạn không thể tái tạo cùng kết quả xác minh sau khi restore, tamper-evidence của bạn sẽ khó bảo vệ.

Nếu bạn đang xây công cụ nội bộ và workflow admin với AppMaster (appmaster.io), chuẩn hóa việc ghi event audit qua các quy trình phía server nhất quán giúp giữ schema sự kiện và correlation id đồng nhất giữa các tính năng, làm cho xác minh và điều tra đơn giản hơn nhiều.

Lên lịch bảo trì cho hệ thống này. Audit trail thường thất bại âm thầm khi nhóm ra tính năng mới nhưng quên thêm event, cập nhật đầu vào hash, hoặc giữ các job xác minh và bài tập restore chạy.

Dễ dàng bắt đầu
Tạo thứ gì đó tuyệt vời

Thử nghiệm với AppMaster với gói miễn phí.
Khi bạn sẵn sàng, bạn có thể chọn đăng ký phù hợp.

Bắt đầu