สิทธิ์แบบลำดับชั้น: materialized path, การสืบทอด และ scope ที่ผูกกับ resource จริง
องค์กรเป็น tree และสิทธิ์ต้องสืบทอดลง subtree — แต่ถ้าคุณไต่ tree ทุก request คุณจะช้าและพัง ผมเล่าวิธีที่ใช้จริง: materialized path + prefix match, ผูก role ที่ node แล้วสืบทอดลง, และบทเรียนแพงสุดคือ enforce scope ต้องเช็คที่ตัว resource ไม่ใช่แค่ชื่อ role
อ่าน ~9 นาที
โมเดลสิทธิ์แบบ tree: org unit → project → zone → device
ผมเคยทำระบบประกาศเสียงสำหรับหน่วยงานท้องถิ่นไทยแห่งหนึ่ง ที่โครงสร้างองค์กรเป็นลำดับชั้นแท้ๆ คือ ส่วนกลาง → หน่วยระดับตำบล → หมู่บ้าน แล้วแต่ละหน่วยมี "โปรเจกต์" ของตัวเอง ในโปรเจกต์มี zone มี device (ลำโพงจริง) ห้อยอยู่อีกที ความต้องการทางธุรกิจตรงไปตรงมา คือ คนของส่วนกลางต้องคุมได้ทั้งหมด คนของตำบลคุมได้เฉพาะหมู่บ้านใต้ตำบลตัวเอง และผู้ใหญ่บ้านคุมได้แค่หมู่บ้านตัวเอง
พูดง่ายๆ คือ องค์กรเป็น tree และสิทธิ์ต้อง สืบทอดลง subtree ทันทีที่คุณเห็นรูปทรงนี้ คำถามวิศวกรรมข้อแรกจะโผล่มาเลย คือ "เวลาจะรู้ว่า user คนนี้เข้าถึง node นี้ได้ไหม ผมจะ resolve มันยังไงให้ไม่ต้องไต่ tree ขึ้นลงทุก request" เพราะถ้าไต่ tree ด้วย recursive query ทุกครั้ง พอ tree ลึกและ traffic เยอะ มันจะกลายเป็นคอขวดทันที
Materialized path + prefix match: resolve subtree ด้วย query เดียว
เทคนิคที่ผมใช้และแนะนำคือ materialized path แต่ละ node เก็บ path ที่เป็นสายของ id ตั้งแต่ราก เช่น /central/tao-a/village-3 พร้อมกับ parentId (ไว้ประกอบ tree) และ depth พอมี path แล้ว การถามว่า "node นี้อยู่ใต้ node นั้นไหม" กลายเป็นการเทียบ prefix ธรรมดาๆ ไม่ต้องไต่ความสัมพันธ์เลย
// helper เดียวที่ทุกการเช็คสิทธิ์เรียกใช้
// under('/central/tao-a/village-3', '/central/tao-a') === true
function under(path, allowedPrefix) {
// ต้องมี '/' ปิดท้าย prefix กัน false-positive:
// '/central/tao-a' ต้องไม่ match '/central/tao-abc'
return (path + '/').startsWith(allowedPrefix + '/');
}
จุดที่คนพลาดบ่อยคือลืมเติม separator ปิดท้าย ถ้าเทียบ path.startsWith('/central/tao-a') ตรงๆ node ที่ชื่อ tao-abc จะถูกนับว่าอยู่ใต้ tao-a ด้วย ซึ่งเป็น scope leak เงียบๆ เติม / ทั้งสองข้างแล้วปัญหานี้หายไป
ข้อดีคือ index ที่ column path ตัวเดียว แล้ว query แบบ "เอาทุก node ใต้ subtree นี้" ก็เป็น range/prefix query ครั้งเดียวจบ อ่านเร็วมาก ราคาที่ต้องจ่ายคือ การย้าย node แพง เวลา reparent node หนึ่งไปใต้ parent ใหม่ คุณต้อง rewrite path กับ depth ของ ทั้ง subtree เป็น bulk update พร้อมกับ sync path ที่ denormalize ไว้ในตาราง role assignment ด้วย นี่คือ trade-off ตรงๆ ของ materialized path คือ อ่านถูกและเร็ว แต่ย้ายแพง สำหรับ org tree ที่ read เยอะกว่า restructure หลายเท่า มันคุ้มมาก และอย่าลืมใส่ cycle guard ตอน reparent กัน node ถูกย้ายไปใต้ลูกหลานตัวเอง
Role inheritance: assign ที่ node แล้วมีผลลงทั้ง subtree
เมื่อมี path แล้ว การสืบทอดสิทธิ์ก็สวยงามตามมา ผมไม่ผูก user เข้ากับ device ทีละตัว แต่ผูก user เข้ากับ node ผ่าน role assignment ที่เก็บ userId, role, และ path ของ node นั้น พอ user มี assignment ที่ /central/tao-a เขาก็ได้สิทธิ์ครอบทุก node ที่ path ขึ้นต้นด้วย /central/tao-a/ โดยอัตโนมัติ ไม่ต้อง grant ซ้ำทีละหมู่บ้าน
user หนึ่งคนมีได้หลาย assignment (เช่นดูแลสองตำบล) เวลา resolve ผมก็รวม path ที่ได้รับอนุญาตทั้งหมดออกมาเป็นลิสต์ แล้วเช็คว่า resource ที่จะเข้าถึงอยู่ใต้ path ตัวใดตัวหนึ่งไหม ประเด็นที่ต้องระวังคือ ผมแยกความหมายเป็นสองระดับ คือ path ที่มองเห็นได้ (ทุก role เห็น) กับ path ที่ประกาศ/แก้ไขได้ (เฉพาะ role ที่มีสิทธิ์เขียน) การมองเห็นกับการกระทำเป็นคนละ scope กัน อย่าเอามารวมเป็นก้อนเดียว
Enforce ที่ resource level: เช็ค scope ที่ตัว device ไม่ใช่แค่ role name
นี่คือบทเรียนที่ผมจ่ายแพงที่สุด และเป็นแก่นของบทความนี้ ตอน design ผมตั้งใจให้ scope ละเอียดถึง ระดับ device คือ device แต่ละตัวเก็บ orgUnitId ของมันเอง แล้วภาพในหัวคือ "เช็คสิทธิ์ที่ device ตัวนั้น" แต่พอผมกลับไปเทส runtime ของจริง ผมพบว่าโค้ดที่บังคับ scope จริงๆ เช็คที่ ระดับ project คือ ดูแค่ project.orgUnitId ไม่เคยแตะ device.orgUnitId เลย
ฟังก์ชันที่ผมคิดว่าเป็นตัวกรอง per-device (partitionDevices) กลายเป็น dead code ไม่มี call site เรียกใช้เลยสักที่ grep ทั้ง repo แล้วยืนยัน field device.orgUnitId ถูกเก็บ ถูกโชว์ในหน้า status สวยงาม แต่ ไม่มีผลกับการตัดสินสิทธิ์ ผลลัพธ์จริงคือ ใครก็ตามที่เข้าถึง orgUnit ของ project ได้ = สั่งได้ทุก device ใน project นั้น ต่อให้ schema สื่อว่ามัน per-device
// สิ่งที่ผมคิดว่าเกิด (per-device) — แต่ไม่เคยถูกเรียก
function partitionDevices(user, devices) {
return devices.filter(d => canAccessOrgUnit(user, d.orgUnitId));
}
// สิ่งที่เกิดจริงตอน start ประกาศ — เช็คที่ project เท่านั้น
function startSession(user, project) {
// scope เช็คด้วย project.orgUnitId — device.orgUnitId ไม่ถูกแตะ
assertCanAccessOrgUnit(user, project.orgUnitId);
// ...ผ่านแล้วสั่งได้ทุก device ใน project
}
บทเรียน transferable คือ granularity ของ schema ไม่เท่ากับ granularity ของ enforcement การมี column ที่ดูเหมือนคุม scope ระดับละเอียด ไม่ได้แปลว่าโค้ดบังคับที่ระดับนั้นจริง สิ่งเดียวที่เชื่อได้คือจุดที่มีบรรทัด assert คั่นก่อน mutate ดังนั้นเวลาออกแบบ ให้ระบุให้ชัดตั้งแต่แรกว่า "หน่วยของ scope" (unit of authorization) อยู่ที่ระดับไหน แล้ว enforce ตรงจุดนั้นทุก path อย่าปล่อยให้ schema กับ code เล่าคนละเรื่อง
Priority / override: หน่วยระดับสูงแทรกหน่วยระดับล่างได้ยังไง
tree ของสิทธิ์ยังต้องตอบอีกคำถาม คือ เวลาสองหน่วยประกาศเสียงชนกันบนลำโพงเดียวกัน ใครชนะ ธุรกิจต้องการให้หน่วยระดับสูงกว่าแทรกได้ ผมจึงให้แต่ละ role มี priority เป็นตัวเลข เช่น หมู่บ้าน < ตำบล < ส่วนกลาง < ประกาศฉุกเฉิน คนที่ priority สูงกว่าสามารถ preempt session ที่กำลังทำงานอยู่ได้
จุดที่ผมอยากเตือนคือ ตอนแรกผมเผลอคิดว่า priority ควร map จาก type ของ node (central/tao/village) โดยตรง แต่ในทางปฏิบัติ type เป็นแค่ label ที่คนใส่ ความหมายจริงของสิทธิ์มาจาก path กับ role ไม่ใช่จาก type ผมเลยแยกเด็ดขาด คือ scope ยึด path ส่วน priority ยึด role ที่ user ได้รับ ถ้า user มีหลาย assignment ก็เอา priority สูงสุด และ priority นี้ผม hardcode ไว้เป็นค่าคงที่ในโค้ด ไม่เก็บ DB เพราะมันคือ policy ของระบบ ไม่ใช่ข้อมูล การเก็บ policy ปนกับ data ทำให้แก้ยากและ audit ยากกว่าเดิม
Legacy mode: เปิดระบบสิทธิ์บนข้อมูลเก่าที่ยังไม่มี org tree
ความจริงที่เจ็บของทุกระบบ RBAC คือ คุณมัก retrofit มันลงบนข้อมูลที่ใช้งานอยู่แล้ว วันที่คุณ deploy tree สิทธิ์ ยังไม่มีใครสร้าง org node สักตัว ถ้าคุณบังคับ scope ทันที ระบบทั้งหมดจะ fail-closed คือทุกคนโดนปฏิเสธ ของเดิมพังหมด
ผมแก้ด้วย legacy mode ที่ตัดสินจาก data โดยตรง คือ ถ้ายังไม่มี org node เลย (count === 0) ให้ระบบเปิดกว้าง ทุกคนเข้าถึงได้เหมือนเดิม แต่ทันทีที่มีคนสร้าง node แม้แค่ตัวเดียว ระบบจะเริ่มบังคับ scope เต็มรูปแบบ
async function allowedPaths(user) {
const orgCount = await OrgUnit.estimatedDocumentCount();
if (orgCount === 0) return 'ALL'; // legacy: ยังไม่ตั้ง tree = ไม่พังของเดิม
return resolveFromAssignments(user); // มี tree แล้ว = บังคับ scope
}
pattern นี้ทำให้ migration ราบรื่น ระบบใหม่กับข้อมูลเก่าอยู่ร่วมกันได้ แต่มันมีกับดักที่ผมโดนมาแล้ว คือ ตอนเทสบนเครื่อง local ผมสร้าง org node ขึ้นมาหนึ่งตัวเพื่อลองฟีเจอร์ แล้วระบบ หลุด legacy mode ทันที ทำให้เทสเดิมที่พึ่งพา "เปิดหมด" พังตามไปด้วย บทเรียนคือ toggle ที่ตัดสินจาก data มันสะดวก แต่ implicit มาก ต้อง cleanup ให้กลับสถานะ 0 เสมอ และเขียน comment เตือนไว้ตรงจุดที่มันสลับโหมด
รูรั่ว: endpoint ใหม่ที่ลืม assert scope = data leak ข้ามหน่วย
รูรั่วที่อันตรายที่สุดของ RBAC แบบ tree ไม่ใช่ตรรกะที่ผิด แต่คือ endpoint ใหม่ที่ลืมเรียกตรรกะนั้น ในอีกระบบหนึ่งที่ผมรีวิว (ระบบจ่ายเงินในองค์กรที่มีหลาย tenant) มี role สองตัวที่หน้าตาคล้ายกัน คือ role หนึ่ง cross-tenant เห็นทุกหน่วย กับอีก role หนึ่งที่ scope จำกัดแค่หน่วยตัวเอง แต่ใน controller คนมักเขียนปนกันเหมือนเป็น tier เดียว @Roles(['admin', 'super']) ทั้งที่ scope จริงต่างกันคนละโลก ปล่อย role ที่ scope จำกัดให้ผ่าน endpoint ที่ return ข้อมูล cross-tenant เมื่อไหร่ = data leak ของหน่วยอื่นทันที
ทางแก้ที่ผมใช้คือทำ helper กลางตัวเดียว เรียกมันว่า assertScope(caller, targetOrgId) ที่ throw 403 ถ้า caller ไม่มีสิทธิ์ในหน่วยของ target แล้วบังคับเป็นกติกาว่า ทุก endpoint ที่ return หรือ mutate resource ที่ผูกหน่วย ต้องเรียก helper นี้ ก่อนเสมอ
function assertScope(caller, targetOrgPath) {
if (caller.isSuperAdmin) return; // cross-scope ได้จริงตัวเดียว
const ok = caller.allowedPaths.some(p => under(targetOrgPath, p));
if (!ok) throw new ForbiddenException(); // ลืมเรียก = รูรั่ว
}
ข้อดีของการมีจุดเดียวคือ code review หา endpoint ที่ลืม assert ได้ง่าย และในเอกสาร permission matrix ผมบังคับให้แยกคอลัมน์ของแต่ละ role ทุกแถว ห้ามรวม role ที่ scope ต่างกันไว้ในช่องเดียว เพราะการรวมคือจุดที่ความเข้าใจผิดเริ่มก่อตัว
สรุป
- ใช้ materialized path + prefix match อย่าไต่ tree ทุก request — เก็บ
pathที่ node แล้วเช็ค scope ด้วยstartsWithอย่าลืมเติม separator ปิดท้ายกัน prefix ชนกันเงียบๆ - ผูก role ที่ node แล้วสืบทอดลง subtree ไม่ต้อง grant ทีละ resource และแยก scope ที่ "มองเห็น" ออกจาก scope ที่ "แก้ไข/สั่งงาน" ได้
- granularity ของ schema ไม่เท่ากับ granularity ของ enforcement — มี column ระดับ device ไม่ได้แปลว่าโค้ดบังคับที่ device จริง ต้องเทส runtime และ enforce ที่ตัว resource
- แยก scope (ยึด path) ออกจาก priority/override (ยึด role) และเก็บ policy อย่าง priority ไว้ในโค้ด ไม่ปนกับ data
- Legacy mode ช่วย retrofit ลงข้อมูลเก่าโดยไม่ fail-closed แต่ toggle ที่ตัดสินจาก data นั้น implicit ต้อง cleanup สถานะและเขียน comment เตือน
- รูรั่วใหญ่สุดคือ endpoint ใหม่ที่ลืม assert — รวมตรรกะ scope ไว้ helper ตัวเดียว แล้วบังคับให้ทุก endpoint ที่แตะ resource ผูกหน่วยเรียกมันเสมอ




