# Bật RLS rồi mà trợ lý AI vẫn đọc được tài liệu phòng nhân sự

> Thủ phạm thường là role kết nối, và cách sửa chỉ cần vài câu SQL đặt đúng chỗ.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-row-level-security-postgres-cho-tro-ly-ai/

Bạn đã chạy `ENABLE ROW LEVEL SECURITY`, viết policy cẩn thận, test bằng psql thấy ổn. Vậy mà trợ lý AI vẫn trả lời câu hỏi của một nhân viên kinh doanh bằng một đoạn trích từ tài liệu nội bộ của phòng nhân sự.

Policy không hỏng. Ứng dụng đang kết nối bằng role owner của bảng, và tài liệu Postgres nói rõ owner bình thường được bỏ qua row security.

Đây là tình huống FDE hay gặp khi đưa một trợ lý RAG vào doanh nghiệp: một kho tài liệu chung, nhiều phòng ban, và yêu cầu "chỉ được thấy dữ liệu của mình". Lọc bằng prompt hay bằng `WHERE` trong code ứng dụng đều phụ thuộc vào việc không ai quên. RLS đẩy quy tắc xuống database, nơi mọi truy vấn đều phải đi qua.

Bài này đi từng bước trên Postgres local: một bảng `documents`, một role `assistant_app`, và một phòng ban được gán theo từng transaction. Bạn cần Postgres, psql và quyền tạo role. Phần cuối có pgvector cho ai đang làm retrieval bằng vector.

## Bước 1–2: dựng bảng rồi xem nó "đóng cửa"

Ví dụ dưới đây đã rút gọn: không có embedding, khoá chính nhập tay để khỏi phải cấp quyền cho sequence. Ràng buộc `CHECK` chặn dòng có phòng ban rỗng, để không tài liệu nào nằm ngoài mọi ranh giới.

```sql
CREATE TABLE documents (
  id bigint PRIMARY KEY,
  department_id text NOT NULL CHECK (department_id <> ''),
  title text,
  content text
);

INSERT INTO documents VALUES
  (1, 'hr',      'Quy chế lương 2026', '...'),
  (2, 'sales',   'Bảng giá đại lý',    '...'),
  (3, 'finance', 'Dự toán quý 4',      '...');

CREATE ROLE assistant_app LOGIN PASSWORD 'doi-mat-khau-nay';
GRANT SELECT, INSERT, UPDATE ON documents TO assistant_app;
```

Giờ bật RLS và ép cả owner phải tuân thủ:

```sql
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
```

**Kiểm tra:** đăng nhập bằng `assistant_app` và chạy `SELECT count(*) FROM documents;`. Kết quả phải là 0. Theo tài liệu Postgres, bảng đã bật RLS mà chưa có policy nào sẽ dùng policy mặc định là từ chối, không dòng nào hiện ra hay sửa được. Mặc định là chặn, và đó là điều bạn muốn.

Câu `FORCE` không phải trang trí. Superuser và role có thuộc tính `BYPASSRLS` luôn bỏ qua RLS, còn owner thì bỏ qua trừ khi bảng được đặt `FORCE ROW LEVEL SECURITY`.

Bài hướng dẫn của Render về RAG multi-tenant trên pgvector coi `FORCE` là bắt buộc. Supabase cũng cảnh báo cùng kiểu rủi ro: secret key của họ đi qua role `service_role`, vốn có `bypassrls`.

Trợ lý AI không bao giờ được cầm loại khoá đó.

## Bước 3: một policy, hai cánh cửa

Supabase gợi ý một cách hình dung dễ nhớ: mỗi policy giống một mệnh đề `WHERE` được gắn vào mọi truy vấn. Cái "WHERE" của bạn so `department_id` với một biến của phiên:

```sql
CREATE POLICY dept_isolation ON documents
  FOR ALL
  TO assistant_app
  USING (department_id = (SELECT current_setting('app.department_id', true)))
  WITH CHECK (department_id = (SELECT current_setting('app.department_id', true)));
```

Câu lệnh này có ba phần đáng đọc kỹ.

**`USING` và `WITH CHECK` canh hai việc khác nhau.** `USING` quyết định những dòng *đang có* nào được thấy hoặc được sửa, còn `WITH CHECK` kiểm tra dòng *mới* sinh ra từ `INSERT` hay `UPDATE`. Thiếu `WITH CHECK`, một agent có quyền ghi có thể đọc tài liệu phòng mình rồi đổi `department_id` để đẩy nó sang phòng khác.

**`TO assistant_app` khoanh vùng policy.** Nếu bỏ trống, policy mặc định áp cho `PUBLIC`, tức mọi role, và bạn sẽ khó biết rule nào đang tác động lên ai.

**`(SELECT ...)` bọc quanh `current_setting`.** Theo Supabase, cách này khiến planner tính giá trị một lần (initPlan) thay vì mỗi dòng một lần. Mẹo chỉ đúng khi kết quả không phụ thuộc dữ liệu của dòng, và biến phòng ban thoả điều kiện đó.

**Kiểm tra:** đăng nhập bằng `assistant_app`, mở một transaction mà không gán phòng ban, rồi chạy `SELECT count(*) FROM documents;`. Kết quả bạn cần thấy là 0. Nếu khác 0, dừng lại và xem lại policy trước khi đi tiếp, vì "quên gán phòng ban" là lỗi chắc chắn sẽ xảy ra trong code thật.

## Bước 4: gán phòng ban cho từng transaction

Render khuyên đặt mã tenant thành biến cục bộ của transaction trước khi chạy truy vấn retrieval. Với ví dụ này, tenant chính là phòng ban:

```sql
BEGIN;
SET LOCAL app.department_id = 'hr';
SELECT id, department_id, title FROM documents;
COMMIT;
```

**Kiểm tra:** chỉ dòng `id = 1` hiện ra. Tiếp theo, thử ghi lấn sang phòng khác:

```sql
BEGIN;
SET LOCAL app.department_id = 'hr';
UPDATE documents SET department_id = 'sales' WHERE id = 1;
ROLLBACK;
```

Lệnh `UPDATE` phải bị Postgres từ chối vì dòng mới vi phạm `WITH CHECK`. Còn `UPDATE ... WHERE id = 2` thì chạy mà không sửa dòng nào, vì `USING` giấu dòng của phòng sales.

Chữ `LOCAL` mới là phần quan trọng. Ứng dụng thật dùng connection pool, và một biến gán ở cấp session có thể còn sót lại cho request kế tiếp của người dùng khác. Biến cục bộ hết hiệu lực khi transaction kết thúc.

Trong code, đừng nối chuỗi giá trị phòng ban vào câu SQL. Giá trị đó phải lấy từ phiên đăng nhập đã xác thực, không bao giờ lấy từ thứ mà LLM sinh ra.

## Vì sao thêm một policy lại có thể mở rộng quyền?

Giả sử ba tháng sau, một đồng nghiệp thêm policy cho phép mọi phòng đọc tài liệu có `title` bắt đầu bằng "Thông báo". Mọi policy permissive áp cho cùng một truy vấn được nối với nhau bằng `OR`. Chỉ một policy viết lỏng tay, như `USING (true)` dùng để debug rồi quên xoá, là đủ mở toang bảng.

Policy restrictive vận hành ngược lại: tất cả phải cùng qua thì một dòng mới được truy cập. Bạn dùng nó làm hàng rào cứng cho ranh giới phòng ban:

```sql
CREATE POLICY dept_guardrail ON documents
  AS RESTRICTIVE
  FOR ALL
  TO assistant_app
  USING (department_id = (SELECT current_setting('app.department_id', true)));
```

**Kiểm tra:** tạo tạm một policy permissive `USING (true)` cho `assistant_app`, rồi chạy lại truy vấn ở Bước 4. Bạn vẫn chỉ thấy dòng của phòng nhân sự. Xoá policy tạm đi sau khi thử.

**Điểm mấu chốt:** Quyền truy cập của trợ lý AI phải nằm trong database, không nằm trong prompt.

## Bước 5: để policy không làm chậm retrieval

Tài liệu Postgres nhận xét rằng policy chỉ dựa vào giá trị của chính dòng đang xét là trường hợp đơn giản nhất và chạy nhanh nhất, nên khuyên thiết kế theo hướng đó. Policy ở trên đúng là như vậy: nó so một cột với một hằng số của transaction, không join hay subquery sang bảng khác.

Phần còn lại là index. Supabase lưu ý rằng một cột chỉ được tính là có index khi nó đứng đầu trong một btree index:

```sql
CREATE INDEX documents_dept_idx ON documents (department_id);
```

Nếu đã có index `(created_at, department_id)` thì nó không giúp gì cho policy này. Nếu cần index ghép, hãy để `department_id` đứng trước.

## Khi có pgvector: kết quả thiếu mà không báo lỗi

Thử hình dung kho có 1.000 đoạn văn, trong đó phòng nhân sự chỉ có 50. Truy vấn vector lấy `LIMIT 10` qua index HNSW sẽ tìm các láng giềng gần nhất trong toàn kho trước, rồi RLS mới lọc. Kết quả có thể chỉ còn hai dòng, LLM trả lời kém, và không có lỗi nào được ghi lại.

Render nêu đúng hiện tượng này: lọc kết hợp với HNSW có thể trả về ít dòng hơn `LIMIT`. Tham số cần tìm là `hnsw.iterative_scan` của pgvector, quyết định index có tiếp tục quét cho đến khi đủ `LIMIT` hay không.

Hãy tra tài liệu của đúng phiên bản pgvector bạn đang cài để biết các giá trị nó nhận, rồi bật nó trong cùng transaction với truy vấn retrieval.

Dù chọn giá trị nào, hãy biến nó thành một phép đo. Bọc chính truy vấn retrieval của bạn trong `count(*)`, chạy dưới role `assistant_app` với phòng ban nhỏ nhất. Đoạn dưới đã rút gọn: phần `ORDER BY` là chỗ bạn dán biểu thức khoảng cách vector đang dùng trong ứng dụng.

```sql
BEGIN;
SET LOCAL app.department_id = 'hr';
SELECT count(*) FROM (
  SELECT id FROM documents
  ORDER BY /* biểu thức khoảng cách vector của bạn */
  LIMIT 10
) AS hits;
COMMIT;
```

**Kiểm tra:** với 50 đoạn của phòng nhân sự, kết quả phải là 10. Nếu nhận 2 hay 3, retrieval đang thiếu ngữ cảnh một cách im lặng. Đưa phép đếm này vào test tự động, so với giá trị nhỏ hơn giữa `LIMIT` và số đoạn thực có của phòng ban, và chạy lại mỗi khi đổi cấu hình index.

## Những lỗi hay gặp, và chúng trông thế nào ở công ty khách hàng

Lỗi phổ biến nhất là lỗi mở đầu bài: chuỗi kết nối của trợ lý dùng lại tài khoản migration, tức owner của bảng, hoặc dùng một khoá admin "cho nhanh". Lỗi thứ hai khó thấy hơn.

Tài liệu Postgres cảnh báo rằng kiểm tra toàn vẹn tham chiếu như khoá ngoại bỏ qua RLS, nên có thể tạo ra "kênh ngầm" làm rò thông tin, chẳng hạn lỗi vi phạm ràng buộc cho người dùng biết một id có tồn tại ở phòng khác.

Ở công ty khách hàng, kỹ năng này hiện ra ngay buổi đầu bàn về bảo mật. Đội an ninh sẽ hỏi: "Làm sao chứng minh trợ lý không đọc được dữ liệu phòng khác?".

Câu trả lời thuyết phục là một role tên rõ ràng, policy nằm trong migration, và một bộ test chạy trong CI cố đọc, ghi lấn ranh giới và phải thất bại. Nên hỏi quyền dùng tài khoản Postgres ít đặc quyền ngay từ tuần đầu, trước khi ai đó gửi bạn mật khẩu của owner.

Nếu bạn đang muốn chuyển sang làm FDE, hãy đưa bài thực hành này vào portfolio dưới dạng một repo nhỏ: schema, policy, test cô lập và một đoạn README giải thích vì sao có `FORCE` và `AS RESTRICTIVE`.

Khi tin tuyển dụng nhắc đến "enterprise data", "permissions" hay "multi-tenant RAG", hãy viết vào CV một dòng cụ thể kiểu "thiết kế cô lập dữ liệu theo phòng ban bằng Postgres RLS cho trợ lý RAG, có test trong CI".

Một dòng như vậy nặng ký hơn một danh sách framework.

Trợ lý AI sẽ ngày càng được cấp thêm quyền ghi, gọi tool và tự chạy truy vấn. Thứ giữ ranh giới khi đó không phải câu "đừng tiết lộ dữ liệu" trong system prompt, mà là câu SQL từ chối ngay ở tầng database.

**Thử ngay tuần này:**

- Chạy SELECT current_user trong code kết nối của trợ lý AI bạn đang làm, rồi kiểm tra role đó có phải owner của bảng hoặc có BYPASSRLS hay không.
- Dựng bảng documents với ba phòng ban trên Postgres local theo các bước trong bài, rồi viết một test tự động cố UPDATE dữ liệu sang phòng khác và phải nhận lỗi.
- Rà mọi policy hiện có trên một bảng thật để tìm policy nào đang để mặc định PUBLIC và policy permissive nào có thể vô tình mở rộng quyền.

## Nguồn

- [PostgreSQL Documentation: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)

- [PostgreSQL Documentation: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)

- [Supabase Docs: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)

- [Multi-tenant RAG with PostgreSQL and pgvector (Render)](https://render.com/articles/multi-tenant-rag-postgresql-pgvector)
