Gia cố bảo mật cho ứng dụng Multi-tenant: Tại sao bạn cần PostgreSQL Row Level Security

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Phân tích một vụ rò rỉ dữ liệu

Hãy tưởng tượng thế này: Bạn vừa ra mắt một SaaS quản lý dự án. Bạn có người dùng từ 50 công ty khác nhau—gọi là các ‘tenant’—tất cả dùng chung một cơ sở dữ liệu. Để ngăn nắp, bạn thêm cột tenant_id vào mọi bảng. Backend của bạn được lập trình để thêm WHERE tenant_id = ? vào mọi truy vấn.

Hệ thống hoạt động trơn tru cho đến bản cập nhật vào một ngày thứ Ba thường nhật. Một lập trình viên đẩy một bản vá nhanh cho dashboard báo cáo nhưng lại quên mất mệnh đề WHERE quan trọng đó. Đột nhiên, Công ty A có thể xem lộ trình phát triển và dự báo tài chính riêng tư của Công ty B. Đây không chỉ là một lỗi phần mềm. Đó là một vụ vi phạm dữ liệu làm bốc hơi lòng tin của khách hàng chỉ trong vài phút.

Tôi đã dành nhiều năm làm việc với MySQL, Postgres và MongoDB. Mỗi loại đều có ưu điểm riêng. Tuy nhiên, tôi nhận ra rằng việc xử lý đa người thuê (multi-tenancy) thuần túy ở tầng ứng dụng là một quả bom nổ chậm. Sai sót của con người là không thể tránh khỏi. Nếu cơ sở dữ liệu của bạn không ‘nhận biết’ được các quy tắc bảo mật, bạn luôn chỉ cách thảm họa một lỗi đánh máy duy nhất.

Sai lầm về ‘Đường ống dữ liệu ngốc nghếch’

Hầu hết các lập trình viên coi cơ sở dữ liệu như một thùng lưu trữ ‘ngốc nghếch’. Chúng ta kết nối qua tài khoản superuser và kỳ vọng ứng dụng sẽ là người gác cổng duy nhất. Điều này tạo ra một khoảng cách nguy hiểm giữa dữ liệu và các chính sách bảo mật của bạn.

Khi logic bảo mật chỉ nằm trong mã Node.js hoặc Python, bạn sẽ gặp phải ba rào cản:

  • Phình to mã nguồn: Bạn phải chèn bộ lọc tenant vào hàng trăm truy vấn một cách thủ công.
  • Khó khăn trong bảo trì: Mỗi bảng mới đều yêu cầu một bộ lọc mới trong nhiều file khác nhau.
  • Ác mộng kiểm định: Chứng minh sự cô lập dữ liệu với các chuyên gia tuân thủ (như SOC2) khó khăn hơn nhiều khi nó phụ thuộc vào 10.000 dòng mã ứng dụng.

Ba cách để cô lập dữ liệu

Tôi đã thấy nhiều đội ngũ thử các chiến lược khác nhau để giải quyết vấn đề này. Dưới đây là hiệu quả thực tế của chúng trong môi trường production.

1. Bộ lọc thủ công

Đây là cách làm ‘truyền thống’. Bạn thêm tenant_id vào các truy vấn SQL hoặc sử dụng hook của ORM. Cách này thiết lập nhanh nhưng cực kỳ mong manh. Chỉ cần một truy vấn SQL thuần cho một lệnh join phức tạp là đủ để rò rỉ dữ liệu trên toàn bộ nền tảng.

2. Mỗi Tenant một cơ sở dữ liệu

Trong mô hình này, mỗi khách hàng có cơ sở dữ liệu hoặc schema riêng. Sự cô lập là tuyệt đối. Tuy nhiên, việc quản lý migration cho hơn 500 schema riêng biệt là một cơn ác mộng về vận hành. Nó cũng tiêu tốn bộ nhớ hệ thống và giới hạn kết nối rất nhanh.

3. Row Level Security (RLS)

RLS là giải pháp cân bằng nhất. Nó cho phép bạn định nghĩa các quy tắc bảo mật trực tiếp trên bảng. Khi người dùng thực hiện truy vấn, PostgreSQL tự động và âm thầm thêm các bộ lọc cần thiết. Ngay cả khi ứng dụng của bạn gửi SELECT * FROM tasks, Postgres cũng chỉ trả về những gì người dùng được phép xem.

Triển khai PostgreSQL RLS

RLS biến bảo mật thành một tính năng nội tại của cơ sở dữ liệu. Nó đảm bảo cơ sở dữ liệu là lớp phòng thủ cuối cùng, ngay cả khi mã backend của bạn có lỗ hổng zero-day. Hãy cùng xây dựng nó.

Bước 1: Cấu trúc bảng

Chúng ta sẽ bắt đầu với một bảng công việc cơ bản. Hãy chú ý cột tenant_id—đây là điểm neo của chúng ta.

-- Tạo bảng với cột tenant_id
CREATE TABLE tasks (
    id SERIAL PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT
);

-- Dữ liệu mẫu cho hai công ty khác nhau
INSERT INTO tasks (tenant_id, title) VALUES 
('acme_corp', 'Sửa lỗi đăng nhập'),
('acme_corp', 'Cập nhật tài liệu'),
('globex', 'Thuê thiết kế mới'),
('globex', 'Chuẩn bị báo cáo quý 3');

Bước 2: Bật tính năng

Theo mặc định, RLS không hoạt động. Bạn phải bật nó một cách rõ ràng cho từng bảng. Điều này ngăn chặn việc vô tình bị khóa quyền truy cập trong quá trình thiết lập.

ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

Mẹo: Chủ sở hữu bảng vẫn có thể thấy mọi thứ. Bạn nên tạo một role cụ thể cho ứng dụng để sử dụng trong các hoạt động hàng ngày.

CREATE ROLE app_user WITH LOGIN PASSWORD 'secure_pass_99';
GRANT ALL ON tasks TO app_user;

Bước 3: Tạo Policy

Chúng ta cần một cách để cho Postgres biết tenant nào đang hoạt động. Chúng ta có thể sử dụng một biến session. Policy của chúng ta sẽ là: “Chỉ hiển thị các hàng mà tenant_id khớp với thiết lập phiên làm việc của chúng ta.”

CREATE POLICY task_isolation_policy ON tasks
    USING (tenant_id = current_setting('app.current_tenant', true));

Bước 4: Thử nghiệm thực tế

Hãy mô phỏng việc ứng dụng kết nối với tư cách app_user và chuyển đổi giữa các ngữ cảnh.

-- Chuyển sang user bị giới hạn
SET ROLE app_user;

-- Thiết lập ngữ cảnh cho Acme Corp
SET app.current_tenant = 'acme_corp';

-- Truy vấn này giờ đây sẽ 'tự động' lọc kết quả
SELECT * FROM tasks;

Truy vấn chỉ trả về các công việc của Acme. Các hàng của Globex hoàn toàn vô hình đối với database engine trong phiên làm việc này. Điều này xảy ra mà không cần bất kỳ mệnh đề WHERE nào trong câu lệnh SQL của ứng dụng.

Các phương pháp tốt nhất khi triển khai thực tế

Trong môi trường thực tế, bạn không nên chạy SET ROLE thủ công. Backend của bạn (Node.js, Go, hoặc Python) nên xử lý việc này bên trong một transaction. Khi một request gửi đến API, hãy trích xuất tenant ID từ JWT và thực hiện các lệnh gọi cơ sở dữ liệu như sau:

BEGIN;
SET LOCAL app.current_tenant = 'acme_corp';
-- Logic ứng dụng của bạn chạy ở đây
SELECT * FROM tasks;
COMMIT;

Sử dụng SET LOCAL là rất quan trọng. Nó đảm bảo thiết lập chỉ nằm trong phạm vi transaction đó và không bị ảnh hưởng sang những người dùng khác dùng chung kết nối trong pool.

Vấn đề về hiệu năng

RLS có làm chậm hệ thống không? Trong các bài kiểm tra của tôi, độ trễ thường dưới 2-3 miligiây. PostgreSQL xử lý RLS policy giống như một mệnh đề WHERE tiêu chuẩn trong quá trình <a href="https://itnotes.dev/vi/cach-doc-ke-hoach-thuc-thi-execution-plans-giai-ma-bi-an-truy-van-cham-trong-postgres-va-mysql/">lập kế hoạch truy vấn</a>. Để đảm bảo tốc độ, hãy chắc chắn rằng bạn đã đánh index cho cột <code>tenant_id.

CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);

Lời kết

Chuyển logic bảo mật vào cơ sở dữ liệu có vẻ hơi ngược với thói quen nếu bạn đã quen với việc xử lý mọi thứ ở tầng ứng dụng. Nhưng sự an tâm mà nó mang lại là hoàn toàn xứng đáng. RLS cung cấp một phương pháp khai báo, cực kỳ an toàn để đảm bảo Công ty A không bao giờ thấy dữ liệu của Công ty B—bất kể đội ngũ của bạn phát triển nhanh thế nào hay mã nguồn trở nên phức tạp ra sao.

Lần tới khi bạn bắt đầu một dự án multi-tenant, hãy để PostgreSQL đảm nhận phần việc nặng nhọc này. Đó là cách đáng tin cậy nhất để bạn có thể ngủ ngon vào mỗi tối.

Share: