Phân quyền bảng lương: lọc dòng, che cột, và chỗ che cột không đủ
Dựng phân quyền bảng lương 2.400 nhân viên trên SQL Server, rồi đo số truy vấn khôi phục đúng lương một người bị che bằng data masking.
9:12 sáng thứ Hai, quản lý cửa hàng 3 của BanHang mở báo cáo lương trong ERP. Màn hình liệt kê 20 nhân viên cửa hàng mình, kèm lương tháng 9. Người quản lý đó tò mò lương của một quản lý cửa hàng khác, và của một trưởng phòng ở hội sở. Trong ứng dụng không có nút nào mở ra con số đó. Câu hỏi của bài này: nếu người đó có thêm một quyền đọc cơ sở dữ liệu, các lớp phân quyền trên BanHang chặn được tới đâu. Bài dựng bảng lương 2.400 nhân viên trên SQL Server, đi qua row-level security, dynamic data masking, rồi đo một truy vấn suy luận khôi phục đúng lương một người dù cột đã bị che.
Đọc nhanh
- Row-level security lọc dòng theo cửa hàng, ngữ cảnh người dùng truyền qua
SESSION_CONTEXTđặt read-only. Predicate phải gắn lên từng bảng: chỉ gắn lên bảng nhân viên thì quản lý cửa hàng 3 vẫn đọc 2.400 dòng lương; gắn cả bảng lương thì còn 20. - Dynamic data masking che cột lương cho vai trò kế toán, nhưng người chạy được truy vấn ad hoc suy ra giá trị thật bằng
WHEREtìm nhị phân. Đo được: 26 truy vấn khôi phục đúng lương một người trên dải 8–56 triệu. Microsoft ghi rõ giới hạn này trong tài liệu. - Cách đúng cho kế toán: không cấp quyền đọc bảng chi tiết; cho gọi thủ tục chạy
EXECUTE AS OWNERchỉ trả tổng theo cửa hàng, kèm ngưỡng số người tối thiểu. Truy vấn suy luận khi đó bị từ chối ngay ở quyềnSELECT. - SQL Server Audit ghi vết ai xem lương, chạy được cả trên Express/LocalDB. Audit
EXECUTEthủ tục mới truy ra người thật, vìSELECTbên trong module chạy dưới chủ sở hữu.
Mọi số đo trong bài chạy trên SQL Server 2019 Express qua LocalDB (15.0.4382.1, CU27), database riêng Kumeo_bangluong. Hạt giống của dữ liệu là chính NhanVienId, nên chạy lại cho cùng con số. Lương và phân bố nhân viên là minh họa.
1. Báo cáo lương và ba vai trò
Bảng lương có hai bảng: nhân viên gắn với một cửa hàng, và dòng lương mỗi tháng. Đây là hai bảng mới của BanHang, mảng nhân sự.
CREATE TABLE dbo.NhanVien (
NhanVienId int NOT NULL CONSTRAINT PK_NhanVien PRIMARY KEY CLUSTERED,
CuaHangId int NOT NULL, -- cửa hàng hiện tại
HoTen nvarchar(100) NOT NULL,
ChucDanh nvarchar(50) NOT NULL
);
CREATE TABLE dbo.BangLuong (
Ky date NOT NULL,
NhanVienId int NOT NULL,
CuaHangId int NOT NULL, -- cửa hàng trong kỳ này
LuongThang decimal(18, 2) NOT NULL,
CONSTRAINT PK_BangLuong PRIMARY KEY CLUSTERED (Ky, NhanVienId)
);
BangLuong.CuaHangId không phải bản chép thừa của NhanVien.CuaHangId. Nó ghi nhân viên thuộc cửa hàng nào trong kỳ lương đó. Nhân viên chuyển từ cửa hàng 3 sang cửa hàng 5 tháng 10 thì dòng lương tháng 9 vẫn thuộc cửa hàng 3: quản lý cửa hàng 3 vẫn xem được kỳ cũ, quản lý cửa hàng 5 chỉ thấy từ kỳ mới. Cột này cũng là thứ mục 2 dùng để lọc dòng lương.
Lương dùng decimal(18, 2) như mọi cột tiền của BanHang. Dữ liệu minh họa: 2.400 nhân viên trên 120 cửa hàng, lương tháng 9/2026 trải đều 8.000.000 đến 56.000.000 đồng. Để cho thấy một tình huống ở mục 4, phân bố cố ý không đều: cửa hàng 1 có 36 người, cửa hàng 2 đến 119 mỗi nơi 20 người, cửa hàng 120 mới khai trương chỉ 4 người.
Ba vai trò, ba phần dữ liệu được thấy:
Ba vai trò là ba database principal riêng: nhansu, ketoan, quanly. Vai trò quyết định được ở mức database. Chiều "cửa hàng nào" của quản lý thì không: mọi quản lý đều là principal quanly, nên cửa hàng cụ thể phải đến từ ngữ cảnh phiên do ứng dụng đặt.
CREATE USER nhansu WITHOUT LOGIN; -- nhân sự: xem tất cả, lương thật
CREATE USER ketoan WITHOUT LOGIN; -- kế toán: chỉ tổng theo cửa hàng
CREATE USER quanly WITHOUT LOGIN; -- quản lý: chỉ cửa hàng mình
2. Row-level security: lọc dòng theo cửa hàng
Row-level security (RLS, lọc dòng ở tầng database) gắn một predicate vào bảng. Predicate là một hàm trả bảng nội tuyến (inline table-valued function): trả một dòng nghĩa là "cho xem", trả rỗng nghĩa là "lọc đi". Hàm lấy cửa hàng của phiên từ SESSION_CONTEXT, một túi khóa–giá trị gắn với kết nối. RLS có từ SQL Server 2016.
CREATE SCHEMA baomat;
GO
CREATE FUNCTION baomat.fn_loc_cuahang(@CuaHangId AS int)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS cho_phep
WHERE USER_NAME() IN (N'nhansu', N'ketoan', N'dbo') -- nhân sự, kế toán: toàn bộ
OR CAST(SESSION_CONTEXT(N'CuaHangId') AS int) = @CuaHangId; -- quản lý: cửa hàng trong phiên
GO
CREATE SECURITY POLICY baomat.pol_bangluong
ADD FILTER PREDICATE baomat.fn_loc_cuahang(CuaHangId) ON dbo.NhanVien,
ADD FILTER PREDICATE baomat.fn_loc_cuahang(CuaHangId) ON dbo.BangLuong
WITH (STATE = ON);
Một hàm, gắn lên hai bảng. Predicate chỉ lọc đúng bảng nó được gắn: RLS không đi theo phép join hay theo khóa ngoại sang bảng khác. Bản đầu của bài này chỉ gắn lên dbo.NhanVien, trong khi quanly có SELECT và UNMASK trên dbo.BangLuong. Kết quả đo: quản lý cửa hàng 3 đếm NhanVien ra 20, nhưng đếm BangLuong ra 2.400 dòng của 120 cửa hàng, và đọc được lương thật 49.509.175 của NV1313 ở cửa hàng 65. Mỗi bảng chứa dữ liệu cần lọc phải có predicate riêng.
Nhánh USER_NAME() cho nhân sự và kế toán thấy mọi cửa hàng; nhánh SESSION_CONTEXT lọc quản lý theo cửa hàng. Nếu ngữ cảnh chưa đặt, SESSION_CONTEXT trả NULL, phép so sánh ra NULL, dòng bị lọc. Mặc định là đóng. Đặt predicate trong schema riêng baomat để tách quyền trên hàm khỏi bảng đích; với SCHEMABINDING = ON, người dùng không cần quyền SELECT trên hàm vẫn truy vấn được bảng.
Ứng dụng mở kết nối bằng một login ít quyền, đặt ngữ cảnh sau khi người dùng đăng nhập, rồi mới truy vấn. Cờ @read_only = 1 khóa khóa đó đến khi kết nối đóng (trả về pool), để một request sau trên cùng kết nối không đổi được cửa hàng.
sequenceDiagram participant U as Quản lý CH3 participant App as App ERP participant DB as SQL Server U->>App: Đăng nhập App->>DB: Mở kết nối (login app, ít quyền) App->>DB: sp_set_session_context 'CuaHangId'=3, read_only=1 App->>DB: SELECT ... FROM dbo.BangLuong DB->>DB: RLS gọi fn_loc_cuahang(CuaHangId) DB-->>App: Chỉ 20 dòng lương cửa hàng 3
Phép thử: đặt cửa hàng 3 rồi đọc dưới ngữ cảnh quanly, và đọc dưới ngữ cảnh nhansu.
EXEC sys.sp_set_session_context @key = N'CuaHangId', @value = 3;
EXECUTE AS USER = 'quanly';
SELECT (SELECT COUNT(*) FROM dbo.NhanVien) AS nhan_vien,
(SELECT COUNT(*) FROM dbo.BangLuong) AS dong_luong,
(SELECT COUNT(DISTINCT CuaHangId) FROM dbo.BangLuong) AS so_ch,
(SELECT LuongThang FROM dbo.BangLuong WHERE NhanVienId = 1313) AS luong_nv1313;
REVERT;
EXECUTE AS USER = 'nhansu';
SELECT (SELECT COUNT(*) FROM dbo.NhanVien) AS nhan_vien,
(SELECT COUNT(*) FROM dbo.BangLuong) AS dong_luong,
(SELECT COUNT(DISTINCT CuaHangId) FROM dbo.BangLuong) AS so_ch,
(SELECT LuongThang FROM dbo.BangLuong WHERE NhanVienId = 1313) AS luong_nv1313;
REVERT;
Bảng dưới gộp thêm hai lần đo cùng câu lệnh: lúc policy mới gắn lên NhanVien (gỡ bằng ALTER SECURITY POLICY ... DROP FILTER PREDICATE ON dbo.BangLuong), và lúc phiên chưa đặt CuaHangId.
| Ngữ cảnh | nhan_vien | dong_luong | so_ch | luong_nv1313 |
|---|---|---|---|---|
quanly, CuaHangId = 3, policy chỉ trên NhanVien |
20 | 2.400 | 120 | 49.509.175 |
quanly, CuaHangId = 3, policy trên cả hai bảng |
20 | 20 | 1 | NULL |
quanly, chưa đặt ngữ cảnh |
0 | 0 | 0 | NULL |
nhansu |
2.400 | 2.400 | 120 | 49.509.175 |
Filter predicate lọc im lặng: ứng dụng không biết có dòng bị bỏ, kết quả chỉ ngắn đi. Nhưng RLS ở đây tin rằng SESSION_CONTEXT do ứng dụng đặt đúng. Người chỉ truy cập qua ứng dụng không đặt lại được (khóa read-only). Người có kết nối SQL trực tiếp thì tự đặt ngữ cảnh của mình, kể cả đặt CuaHangId của cửa hàng khác. RLS theo ngữ cảnh phiên là kiểm soát trong một ứng dụng dùng chung bảng, không thay cho việc cấp quyền tối thiểu cho từng người.
3. Che cột lương, và chỗ che cột không đủ
Kế toán cần nhìn bảng lương để đối soát, nhưng không được xem lương từng người. Dynamic data masking (DDM, che dữ liệu động, có từ SQL Server 2016) đổi cách một cột hiện với người không có quyền UNMASK. Dữ liệu trong bảng không đổi; chỉ kết quả truy vấn bị che.
ALTER TABLE dbo.BangLuong
ALTER COLUMN LuongThang ADD MASKED WITH (FUNCTION = 'default()');
GRANT SELECT ON dbo.BangLuong TO nhansu, quanly, ketoan; -- cấp SELECT cho kế toán là thiết kế SAI
GRANT UNMASK TO nhansu; -- nhân sự xem lương thật
GRANT UNMASK TO quanly; -- quản lý xem lương thật của cửa hàng mình
-- ketoan KHÔNG có UNMASK
Hàm default() che số bằng 0. Dưới ngữ cảnh ketoan, cột LuongThang trả .00; dưới nhansu, trả số thật. Trên SQL Server 2019, UNMASK chỉ cấp ở mức database. Quyền UNMASK mịn theo schema, bảng hay cột là tính năng của SQL Server 2022.
Vấn đề: default() che số bằng 0 khi trả kết quả, nhưng điều kiện WHERE vẫn chạy trên giá trị thật. Kế toán không đọc thẳng được lương, nhưng dò được bằng một dãy câu hỏi có/không. Hỏi "lương người này có ≤ X không" bằng cách xem truy vấn có trả dòng hay không, rồi tìm nhị phân.
-- Chạy dưới ngữ cảnh ketoan. Mỗi vòng lặp là một truy vấn ad hoc.
DECLARE @lo bigint = 8000000, @hi bigint = 56000000, @mid bigint, @n int = 0;
WHILE @lo < @hi
BEGIN
SET @mid = (@lo + @hi) / 2;
SET @n += 1;
IF EXISTS (SELECT 1 FROM dbo.BangLuong WHERE NhanVienId = 1313 AND LuongThang <= @mid)
SET @hi = @mid;
ELSE
SET @lo = @mid + 1;
END
SELECT @lo AS luong_khoi_phuc, @n AS so_truy_van; -- 49509175.00, 26
Lương cột hiện ra là 0, nhưng WHERE LuongThang <= @mid lọc theo giá trị thật, nên sau 26 truy vấn @lo hội tụ về đúng 49.509.175 đồng. Thử ba người khác nhau trên cùng dải 8–56 triệu, cả ba đều cần đúng 26 truy vấn: NV73 ra 15.645.217, NV1313 ra 49.509.175, NV1500 ra 21.093.497. Số truy vấn bằng số bit của khoảng cần dò, tức khoảng log2(độ rộng): khoảng càng hẹp (kẻ dò càng biết trước) thì càng ít.
Đây không phải lỗi cài đặt. Microsoft ghi thẳng trong tài liệu: "Dynamic data masking không nhằm chặn người dùng kết nối trực tiếp và chạy các truy vấn vét cạn để lộ từng phần dữ liệu nhạy cảm", và nêu đúng ví dụ dò lương bằng WHERE Salary > 99999 and Salary < 100001. RLS cũng có kênh phụ tương tự khi người dùng chạy được truy vấn tùy ý: tài liệu dẫn SELECT 1/(SALARY-100000) FROM PAYROLL WHERE NAME='John Doe', lỗi chia cho 0 cho biết lương đúng bằng 100.000. Cả hai là lớp giảm phơi bày cho ứng dụng, không phải ranh giới chống người có quyền đọc và quyền chạy truy vấn ad hoc.
4. Cách đúng cho kế toán: chỉ trả tổng theo cửa hàng
Gốc của lỗ hổng ở mục 3 là cấp cho kế toán quyền SELECT trên bảng chi tiết. Bỏ quyền đó đi. Kế toán chỉ cần tổng theo cửa hàng, nên cho họ một đường đọc chỉ trả tổng.
REVOKE SELECT ON dbo.BangLuong FROM ketoan;
REVOKE SELECT ON dbo.NhanVien FROM ketoan;
Một cái bẫy: view gộp sẵn vẫn không đủ. Che cột đi theo ngữ cảnh người gọi, và SUM trên một cột đã che cũng bị che. Kế toán đọc view tổng vẫn ra .00, dù ownership chaining đã cho qua phần quyền SELECT.
CREATE VIEW baomat.v_TongLuongCuaHang AS
SELECT Ky, CuaHangId, COUNT(*) AS SoNhanVien, SUM(LuongThang) AS TongLuong
FROM dbo.BangLuong
GROUP BY Ky, CuaHangId HAVING COUNT(*) >= 5;
-- ketoan SELECT view -> TongLuong = .00 vì mask theo ngữ cảnh ketoan
Cách chạy được là một thủ tục WITH EXECUTE AS OWNER: phần thân chạy dưới ngữ cảnh chủ sở hữu (dbo, có UNMASK), nên SUM tính trên lương thật, và chỉ con số tổng rời khỏi thủ tục. Kế toán chỉ được EXECUTE, không chạm bảng chi tiết. Ownership chaining lo phần quyền trên bảng, nên không phải cấp SELECT cho kế toán.
CREATE PROCEDURE baomat.usp_TongLuongCuaHang
@Ky date
WITH EXECUTE AS OWNER
AS
BEGIN
SET NOCOUNT ON;
SELECT CuaHangId, COUNT(*) AS SoNhanVien, SUM(LuongThang) AS TongLuong
FROM dbo.BangLuong
WHERE Ky = @Ky
GROUP BY CuaHangId
HAVING COUNT(*) >= 5 -- ẩn cửa hàng dưới 5 người
ORDER BY CuaHangId;
END
GO
GRANT EXECUTE ON baomat.usp_TongLuongCuaHang TO ketoan;
Thủ tục gộp theo BangLuong.CuaHangId của từng kỳ, nên tổng tháng 9 của cửa hàng 3 không đổi khi tháng 10 có người chuyển đi. Bên trong thủ tục, USER_NAME() là dbo, nằm trong nhánh "toàn bộ" của predicate, nên RLS không lọc mất cửa hàng nào. Quản lý gọi thủ tục này thì bị từ chối (The EXECUTE permission was denied), vì chỉ ketoan được cấp.
Mệnh đề HAVING COUNT(*) >= 5 là ngưỡng số người tối thiểu. Một cửa hàng chỉ có một người thì tổng bằng chính lương người đó; cửa hàng hai ba người thì ai biết lương mình đã suy ra được lương đồng nghiệp. Ngưỡng chặn kênh suy luận này. Trong dữ liệu minh họa, cửa hàng 120 có 4 người nên bị ẩn; kế toán thấy 119 cửa hàng.
EXEC baomat.usp_TongLuongCuaHang @Ky = '2026-09-01' dưới ngữ cảnh ketoan:
CuaHangId SoNhanVien TongLuong
1 36 357749514.00
2 20 257397970.00
3 20 299289570.00
... 119 cửa hàng, cửa hàng 120 (4 người) bị ẩn
Phép thử lại truy vấn suy luận ở mục 3, vẫn dưới ngữ cảnh ketoan:
SELECT 1 FROM dbo.BangLuong WHERE NhanVienId = 1313 AND LuongThang <= 50000000;
-- Msg 229: The SELECT permission was denied on the object 'BangLuong'
Không còn quyền SELECT trên bảng chi tiết, dãy câu hỏi có/không ở mục 3 dừng ngay ở câu đầu. Kế toán vẫn đối soát được tổng, không còn đường chạm tới lương từng người.
5. Ghi vết ai xem lương
Nhân sự và quản lý vẫn xem được lương thật, đúng vai trò. Cần biết ai đã xem gì, lúc nào. SQL Server Audit ghi vết việc đọc. Mọi edition đều có server audit; database audit có trên mọi edition từ SQL Server 2016 SP1. Kiểm trên LocalDB (nền Express): cả server audit ghi ra file lẫn database audit specification đều chạy, và fn_get_audit_file đọc lại được.
CREATE SERVER AUDIT AU_bangluong
TO FILE (FILEPATH = 'D:\Audit\')
WITH (QUEUE_DELAY = 1000, ON_FAILURE = CONTINUE);
ALTER SERVER AUDIT AU_bangluong WITH (STATE = ON);
GO
CREATE DATABASE AUDIT SPECIFICATION DAS_bangluong
FOR SERVER AUDIT AU_bangluong
ADD (SELECT ON dbo.BangLuong BY public),
ADD (EXECUTE ON baomat.usp_TongLuongCuaHang BY public)
WITH (STATE = ON);
Audit tạo ra ở trạng thái tắt, phải STATE = ON mới ghi. Với ON_FAILURE = CONTINUE, lỗi ghi audit không làm dừng lệnh; đổi sang SHUTDOWN thì một lỗi ghi audit sẽ dừng cả instance, chỉ dùng khi quy định bắt buộc không được mất một dòng vết nào. Sau khi nhân sự xem lương một người, quản lý cửa hàng 3 chạy SELECT NhanVienId, LuongThang FROM dbo.BangLuong (không WHERE, RLS trả 20 dòng), và kế toán gọi thủ tục tổng, đọc lại file:
SELECT event_time, action_id, database_principal_name AS nguoi, object_name,
CONVERT(varchar(80), statement) AS cau_lenh
FROM sys.fn_get_audit_file('D:\Audit\AU_bangluong*.sqlaudit', DEFAULT, DEFAULT)
WHERE action_id IN ('SL', 'EX') ORDER BY event_time;
| nguoi | action | object | câu lệnh (rút gọn) |
|---|---|---|---|
nhansu |
SL | BangLuong | SELECT LuongThang FROM dbo.BangLuong WHERE NhanVienId = 1313 |
quanly |
SL | BangLuong | SELECT NhanVienId, LuongThang FROM dbo.BangLuong |
ketoan |
EX | usp_TongLuongCuaHang | EXEC baomat.usp_TongLuongCuaHang @Ky = '2026-09-01' |
dbo |
SL | BangLuong | SELECT CuaHangId, COUNT(*) AS SoNhanVien, SUM(LuongThang)... |
Audit ghi cả tên người và nguyên văn câu lệnh. Lưu ý dòng cuối: lệnh SELECT bên trong thủ tục được ghi dưới dbo, vì thân thủ tục chạy EXECUTE AS OWNER. Muốn truy ra con người đã xem tổng thì phải audit chính hành vi EXECUTE thủ tục, nơi ghi đúng ketoan. Đọc file audit cần quyền: SQL Server 2019 trở về trước cần CONTROL SERVER; từ SQL Server 2022, VIEW SERVER SECURITY AUDIT là đủ.
Lương là dữ liệu cá nhân
Từ 01/01/2026, Luật Bảo vệ dữ liệu cá nhân số 91/2025/QH15 có hiệu lực (Cổng thông tin Bộ Công an). Luật trao cho chủ thể dữ liệu các quyền như xem và yêu cầu chỉnh sửa dữ liệu cá nhân của mình (Điều 4). Kiểm soát ai được đọc lương và ghi vết việc đọc là các biện pháp kỹ thuật phục vụ hướng đó; bài này không trích dẫn điều khoản kỹ thuật cụ thể vì chưa đối chiếu được toàn văn hợp nhất.
Những chỗ hay hiểu sai
- "Che cột là đủ để giấu lương." Che cột chỉ đổi kết quả hiển thị;
WHEREvẫn lọc trên giá trị thật, nên người chạy được truy vấn ad hoc dò ra số thật trong khoảng 26 lần. Không cấp quyền đọc bảng chi tiết mới là ranh giới. - "Lọc bảng nhân viên là lương cũng được lọc theo." Predicate chỉ lọc bảng nó được gắn, không đi theo join. Policy chỉ trên
NhanVienđể quản lý cửa hàng 3 đọc 2.400 dòngBangLuong. Gắn predicate lên mọi bảng chứa dữ liệu cần lọc. - "RLS theo
SESSION_CONTEXTchặn được người có quyền đọc." Nó tin ứng dụng đặt ngữ cảnh đúng. Người có kết nối SQL trực tiếp tự đặt ngữ cảnh của mình. RLS là kiểm soát tầng ứng dụng, không thay quyền tối thiểu. - "View tổng là giải quyết xong cho kế toán." Che cột đi theo ngữ cảnh người gọi, nên
SUMtrên cột đã che vẫn ra0. Phải dùng moduleEXECUTE ASđể gộp dưới ngữ cảnh cóUNMASK. - "Audit
SELECTtrên bảng là truy được mọi người xem lương." Lệnh đọc bên trong thủ tụcEXECUTE AS OWNERghi dưới chủ sở hữu, không phải người gọi. Phải audit cảEXECUTE.
Đọc tiếp
- Lưu mật khẩu đúng cách: khi bản backup bị lộ, mọi phân quyền trong database không còn tác dụng, và bài toán đổi sang độ chậm của hàm băm.
- Model Context Protocol: một login chỉ được chạy thủ tục tra cứu, không
SELECTtrực tiếp, là cùng mẫu ownership chaining ở mục 4. - Kiểu dữ liệu, collation và khóa chính: vì sao tiền dùng
decimal, và việc tạo lại security policy sau khi tắt.
Nguồn
- Microsoft Learn, Dynamic Data Masking: mục "Security Note: Bypassing masking using inference or brute-force techniques" và các giới hạn;
UNMASKmịn từ SQL Server 2022. - Microsoft Learn, Row-Level Security: filter predicate, best practices, mục "Security note: side-channel attacks" (chia cho 0).
- Microsoft Learn, sp_set_session_context: khóa
@read_only, giới hạn 1 MB. - Microsoft Learn, SQL Server Audit (Database Engine): edition hỗ trợ,
ON_FAILURE, quyềnfn_get_audit_file. - Microsoft Learn, Editions and supported features of SQL Server 2019: bảng tính năng Security theo edition.
- Cổng thông tin Bộ Công an, Luật Bảo vệ dữ liệu cá nhân số 91/2025/QH15 có hiệu lực từ 01/01/2026.