Gom nhiều quyền vào một con số: bitmask cho quyền người dùng và địa chỉ IP

Lưu cả chục quyền người dùng trong một cột số duy nhất, kiểm tra địa chỉ IP có thuộc dải mạng cho phép không, và những cái bẫy của JavaScript khi tính bằng bit.

Phát LâmĐăng Cập nhật 8 phút đọcTrung cấp
Gom nhiều quyền vào một con số: bitmask cho quyền người dùng và địa chỉ IP
Mục lục
  1. Một con số, nhiều công tắc
  2. Bốn thao tác đủ dùng hằng ngày
  3. Làm thế nào
  4. Cho bạn nào viết code
  5. Dùng bit để kiểm tra địa chỉ IP
  6. Vì sao JavaScript hay làm bạn bất ngờ
  7. Lỗi hay gặp
  8. Nên làm gì tiếp theo

Bảng người dùng của bạn có các cột can_read, can_write, can_delete, is_admin. Tuần sau sếp bảo thêm quyền xuất file, thế là bạn thêm cột thứ năm, sửa API, sửa giao diện, và hy vọng không quên chỗ nào. Có một cách lưu cả năm quyền đó trong một con số duy nhất, không cần đổi bảng khi thêm quyền mới.

Cách đó gọi là bitmask (mặt nạ bit). Nghe giống thứ chỉ dân viết driver mới dùng, nhưng mình gặp nó thường xuyên khi làm web: lưu quyền, bật tắt tính năng, kiểm tra một địa chỉ IP có nằm trong dải được phép không.

Hàng công tắc, một vài công tắc sáng, khung mặt nạ màu hổ phách phủ phía trên
Mỗi bit là một công tắc, mặt nạ chọn ra những công tắc cần xét.

Một con số, nhiều công tắc

Máy tính lưu số bằng các chữ số 0 và 1, mỗi chữ số gọi là một bit. Bạn hãy coi mỗi bit là một công tắc: 1 là bật, 0 là tắt.

Gán mỗi quyền một công tắc: đọc là công tắc số 0, ghi là số 1, xoá là số 2, quản trị là số 3. Con số 5 viết dưới dạng bit là 0101, nghĩa là công tắc đọc và công tắc xoá đang bật. Chỉ một con số, nhưng chứa đủ bốn quyền.

Bạn đã gặp cách này rồi mà có thể không để ý. Lệnh chmod 755 trên Linux chính là bitmask: đọc là 4, ghi là 2, chạy là 1, và 7 = 4 + 2 + 1 nghĩa là được cả ba.

Bốn thao tác đủ dùng hằng ngày

Gọi mask là con số đang giữ các công tắc, và flag là công tắc bạn quan tâm. Bốn việc cần biết:

  • Bật một quyền: mask | flag
  • Tắt một quyền: mask & ~flag
  • Đảo một quyền: mask ^ flag
  • Hỏi "có quyền này không": (mask & flag) === flag

Bạn không cần hiểu từng ký hiệu ngay. Chỉ cần nhớ rằng & hoạt động như một cái khung mặt nạ: đặt lên con số, chỉ nhìn thấy công tắc nào nằm trong khung.

Làm thế nào

  1. Liệt kê quyền, gán mỗi quyền một số là luỹ thừa của 2: 1, 2, 4, 8, 16...
  2. Đặt tên cho từng số trong code (READ, WRITE), đừng viết số trần.
  3. Lưu cả con số vào một cột số nguyên trong database.
  4. Mọi chỗ kiểm tra quyền đều đi qua hàm has, grant, revoke dùng chung, không viết lại phép bit rải rác.
  5. Khi cần thêm quyền, chỉ thêm ở cuối (số tiếp theo trong dãy) và không bao giờ đổi số của quyền đã có. Nếu đổi, dữ liệu cũ trong database sẽ có nghĩa khác.

Một điểm dễ trượt: kiểm tra "có đủ cả hai quyền" khác với "có ít nhất một trong hai". Nhầm hai kiểu này nghĩa là cấp quyền rộng hơn dự định.

Cho bạn nào viết code

Mình chạy lại toàn bộ trên Node 20, Python 3.12 và MariaDB 11.8.

JavaScript:

export const Perm = Object.freeze({
  READ:   1 << 0, // 0001
  WRITE:  1 << 1, // 0010
  DELETE: 1 << 2, // 0100
  ADMIN:  1 << 3, // 1000
});

export const has    = (mask, flag) => (mask & flag) === flag;
export const grant  = (mask, flag) => mask | flag;
export const revoke = (mask, flag) => mask & ~flag;
export const toggle = (mask, flag) => mask ^ flag;

let user = Perm.READ | Perm.WRITE;   // 0011
user = grant(user, Perm.DELETE);     // 0111
user = revoke(user, Perm.WRITE);     // 0101
user = toggle(user, Perm.ADMIN);     // 1101

Các bước ra đúng 0011, 0111, 0101, 1101. Với người dùng ở trạng thái 0101, has(user, READ | WRITE) ra false vì thiếu quyền ghi, còn (user & (READ | WRITE)) !== 0 ra true vì có ít nhất một quyền. Hai kiểu hỏi khác nhau, chọn đúng kiểu bạn muốn.

Bẫy nguy hiểm nhất là dấu ngoặc. Trong JavaScript, === được tính trước &. Viết user & Perm.WRITE === Perm.WRITE bị hiểu thành user & (Perm.WRITE === Perm.WRITE), tức user & true, tức user & 1. Mình thử với người dùng đã bị thu quyền ghi: hàm has trả false đúng, còn biểu thức thiếu ngoặc trả giá trị "đúng" vì công tắc đọc đang bật. Một lỗi phân quyền thật sự. Luôn bọc (mask & flag) trong ngoặc.

Python có sẵn IntFlag làm đúng việc này: Perm.WRITE in user để hỏi, user |= Perm.DELETE để cấp, user &= ~Perm.WRITE để thu, và Perm(5) dựng lại tập quyền từ con số lưu trong database (in ra <Perm.READ|DELETE: 5>).

Lưu trong database chỉ cần một cột perms INT UNSIGNED. Mình thử trên một database tạm với bốn người dùng:

SELECT name FROM users WHERE perms & 2 = 2;                -- ai có quyền ghi
UPDATE users SET perms = perms | 8 WHERE name = 'dung';    -- cấp quản trị
UPDATE users SET perms = perms & ~2 WHERE name = 'an';     -- thu quyền ghi

Ở MySQL và MariaDB, & được tính trước = nên câu đầu đúng như ý (khác với JavaScript). Cái giá: điều kiện perms & 2 = 2 là phép tính trên cột nên database không dùng được index thường, và EXPLAIN báo type: ALL, tức đọc cả bảng. Bảng vài nghìn người dùng thì không sao. Với hàng triệu dòng cần lọc nhanh, hãy dùng cột riêng có index. Lý do chi tiết có trong bài Truy vấn chậm? Cách đọc EXPLAIN và đặt index đúng chỗ.

Dùng bit để kiểm tra địa chỉ IP

Địa chỉ IPv4 thực chất là một số 32 bit. Khi bạn thấy 192.168.10.77/24, con số 24 nghĩa là 24 bit đầu chỉ ra "khu mạng", 8 bit sau chỉ ra "căn nhà trong khu đó". Muốn biết một IP có thuộc khu mạng đó không, bạn đặt cái khung mặt nạ gồm 24 bit 1 lên IP và so với khu mạng.

Lưới 32 ô, 24 ô xanh teal là phần mạng và 8 ô tím là phần máy
Mặt nạ /24: 24 bit phần mạng, 8 bit phần máy.
export function ipToInt(ip) {
  const p = ip.split(".").map(Number);
  if (p.length !== 4 || p.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) throw new Error("IPv4 không hợp lệ");
  return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
}

export function prefixToMask(prefix) {
  if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error("prefix 0..32");
  return prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0;
}

export function inCidr(ip, cidr) {
  const [base, p] = cidr.split("/");
  const mask = prefixToMask(Number(p));
  return ((ipToInt(ip) & mask) >>> 0) === ((ipToInt(base) & mask) >>> 0);
}

Chạy với 192.168.10.77/24 ra mạng 192.168.10.0, mặt nạ 255.255.255.0, 254 máy. Với 10.0.0.5/13 ra mặt nạ 255.248.0.0. Mình còn so hàm này với module ipaddress của Python trên 2.000 ca ngẫu nhiên, không ca nào lệch.

Hai chỗ trong đoạn này không được bỏ. Một là >>> 0, vì JavaScript trả số có dấu 32 bit, nên không có nó thì 200.1.1.1 AND với 255.0.0.0 ra số âm -939524096 và phép so sánh sai. Hai là nhánh prefix === 0, vì JavaScript lấy số bit dịch theo modulo 32, nên x << 32 thành x << 0 và /0 ra mặt nạ toàn 1 thay vì toàn 0. Với IPv6 hoặc việc quan trọng, hãy dùng thư viện có sẵn (ipaddress của Python, net.BlockList của Node).

Vì sao JavaScript hay làm bạn bất ngờ

JavaScript ép mọi phép bit về số nguyên 32 bit có dấu. Mình thử các trường hợp hay gặp:

  • 1 << 31 ra -2147483648, và 1 << 32 ra 1, không phải 4294967296.
  • 2 ** 32 | 0 ra 0. Số vượt 32 bit bị cắt âm thầm, nên dấu thời gian tính bằng mili giây đưa qua | 0 sẽ hỏng.
  • ~5 ra -6, không phải 10.

Cần quá 32 bit thì dùng BigInt: 1n << 40n ra 1099511627776n. Lưu ý không trộn được BigInt với số thường, 1n | 1 ném TypeError. Python không dính các chuyện này vì số nguyên không giới hạn, nhưng ~5 cũng ra -6, và bạn phải tự & 0xFF nếu muốn mô phỏng độ rộng cố định.

Lỗi hay gặp

Lỗi đau nhất là chèn quyền mới vào giữa dãy rồi đánh số lại cho gọn. Dữ liệu đã lưu giữ nguyên con số cũ, chỉ có ý nghĩa của số bị đổi, nên mọi người dùng cũ đổi quyền một cách âm thầm. Vì vậy mới có luật chỉ thêm ở cuối.

Một lỗi quen thuộc khác là dùng số không phải luỹ thừa của 2, ví dụ 3, cho một quyền. Số 3 là hai công tắc cùng bật, nên quyền đó tự dính vào quyền khác.

Còn chuyện hay gặp trong bài toán IP: ai đó dùng | 0 hay >> 0 "cho gọn" rồi tự hỏi vì sao IP lớn bị so sai. Câu trả lời nằm ở phần JavaScript ở trên.

Nên làm gì tiếp theo

Nếu bảng của bạn đang có nhiều cột true/false về quyền hoặc tính năng, thử gom chúng vào một cột bitmask cho dữ liệu nhỏ, và giữ hàm has, grant, revoke ở một chỗ duy nhất. Nếu quyền cần lọc nhanh trên bảng lớn, giữ cột riêng có index. Hai cách không loại trừ nhau, chọn theo chỗ cần.

Node.jsJavaScriptPythonMySQL/MariaDBbitwisebitmaskpermissionssubnetcidrjavascriptbigintpython

Nguồn tham khảo

  1. MDN: Bitwise AND and other bitwise operators
  2. MDN: Operator precedence
  3. MDN: BigInt
  4. Python docs: enum.IntFlag
  5. Python docs: ipaddress
  6. Node.js docs: net.BlockList
  7. MariaDB: Operator precedence