10.4 · RBAC + @auth directive
บทนี้เจาะ authorization layer ของ VTRC — ระบบ 3 แกน (APP_TYPE / Role / Menu) ที่ evaluate ผ่าน @auth directive และ checkSession + checkMenuAuth ปัญหาเด่นคือ CORR-4 RBAC inconsistency ที่ทำให้ silent mis-authorization เป็นไปได้
3 แกนของ authorization
┌─────────────────────────────────────────────────────────────┐
│ Request │
│ apiKey: <device-key> → APP_TYPE (WEB/WEB_ADMIN/...) │
│ Authorization: Bearer → JWT → Session → Users.role │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ @auth(accessRole, accessMenu) directive on field │
│ │
│ Gate 1: getCurrentRoleUser — check Role.roleName match │
│ Gate 2: checkSession — check Session + JSON scan │
│ Gate 3: checkMenuAuth — check RoleAccess menu match │
└─────────────────────────────────────────────────────────────┘| แกน | Source | Gate ที่เช็ค |
|---|---|---|
| APP_TYPE (device class) | apiKey header → API_DEVICE_KEY lookup | ใน signInApp (login เท่านั้น) |
| Role (RBAC) | Users.role JSON blob + Role table | getCurrentRoleUser |
| Menu (ABAC overlay) | RoleAccess table | checkMenuAuth |
ปัญหา — 3 แกนนี้กระจายอยู่ใน 3 ที่ (config, JSON blob, ตารางแยก) ไม่มี single source of truth
@auth directive — ทำงานอย่างไร
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve = defaultFieldResolver } = field
const { accessRole, accessMenu } = this.args
field.resolve = async function (...args) {
const [, , context] = args
if (!context.token) {
return responError('TOKEN_INVALID')
}
const payload = await decode(context.token, JWT_SECRET)
// Verify token
const verify = verifyToken(context.token,
{
expiresIn: JWT_EXP,
issuer: JWT_ISSUER,
jwtId: payload.jti
})
const error = verify.error
if (error) {
if (error.name === 'TokenExpiredError') {
return responError('TOKEN_EXPIRED')
} else {
return responError('TOKEN_INVALID')
}
}
//Check access role and session
await checkSession(verify.data.userId, accessRole, payload.jti, context.device)
await checkMenuAuth(verify.data.userId, accessMenu)
const data = await resolve.apply(this, args);
return data;
}
}
}flow มี 5 ขั้น
- L19-21 — token presence
- L23 — decode เพื่อ pull
jti - L26-31 — verify signature + expiry + issuer + jti
- L43 —
checkSession(role + session) - L44 —
checkMenuAuth(menu overlay)
🟡 ช่องโหว่ — ไม่มี default-deny
directive ทำงานเฉพาะ field ที่ annotated @auth(...) field ที่ลืม annotate เป็น public โดยอัตโนมัติ ตัวอย่างที่พบ — forceLoging, refreshToken, verifyEmpCodeLogin, loginBypass, domainLists, loginNote, fetchAuthSessionMoblie และอีกหลาย mutation ใน typeDefs/authentication.js
ใน Go target ควรใช้ default-deny middleware + allowlist แทน
Gate 1 · getCurrentRoleUser (RBAC role check)
export const getCurrentRoleUser = async (userId, accessRole) => {
const role = await Models.Role.findAll({
attributes: ['roleId'],
where: {
roleName: {
[Op.or]: accessRole
},
// Only active role that can get role
isActive: 1
}
})
const arrayRole = await role.map(value => { return value.roleId })
const userRole = await Models.User.findOne({
attributes: ['role'],
where: {
userId: userId
}
})
log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "userRole", userRole))
const arrayUserRole = userRole ? userRole.role.accessRole : []
log.info(log.logTextFormat('INFO', 'Method:getCurrentRoleUser', 'INFO', "arrayUserRole", arrayUserRole))
const currentUserRole = await arrayRole.filter(f => arrayUserRole.includes(f))
return currentUserRole
}flow คือ
- L127-136 — query #1 load
Roleที่roleNamematch directive arg (['SUPER_ADMIN','ADMIN']) +isActive: 1 - L140-145 — query #2 load
Users.roleJSON blob ของ user - L148 — read
userRole.role.accessRole— คาดหวังว่าเป็น array ของ roleId UUID - L150 — JS-side intersection
🟡 N+1 RBAC query (PERF-4)
ทุก protected field ต้องผ่าน 3 DB round-trip ขั้นต่ำ (Role, User, และ checkMenuAuth อีก 2 query) กับ resolver ที่มี protected field 10 ตัว = 30+ DB round-trip ต่อ request
โดยทั่วไปจะเริ่ม debug "DB connection pool exhausted" ที่นี่ก่อน — เพราะทุก field resolve เพิ่ม N query
🟡 isActive: 1 — BOOLEAN vs INTEGER
isActive: 1isActive เป็น Sequelize.BOOLEAN แต่ compare กับ integer 1 ใน MariaDB มัน coerce ได้ แต่เป็น code smell ที่บ่งบอกว่าไม่มี strict type thinking
Gate 2 · checkMenuAuth (CORR-4 Critical)
export const checkMenuAuth = async (userId, accessMenu) => {
try {
if (!accessMenu) { return }
// const accessableMenu = MENUS_BACK_OFFICE.filter(menu => accessMenu.includes(menu))
let userResult = await Models.User.findAll({ where: { userId: userId } })
const accessRole = await RoleAccess.findByConditions({ where: { roleId: userResult[0].role.accessRole } })
// ใช้ nameEn เนื่องจาก menukey เป็น lowercase
const accessableMenu = accessRole.filter(role => accessMenu.includes(role.nameEN))
log.info(log.logTextFormat('INFO', 'Method:checkMenuAuth', 'QUERY', "Debugging", { userResult, accessRole, accessableMenu }))
// Check access Role
if (accessableMenu.length <= 0) {
// return accessableMenu
responError('TOKEN_INVALID', {}, null, "คุณไม่สิทธิดำเนินการ")
}
return accessableMenu
} catch (err) {
log.error(log.logTextFormat('TOKEN_INVALID', 'Method:checkMenuAuth', 'QUERY', err.stack, { userId, accessMenu }))
// responError(err, {}, "", "Something went wrong")
responError('DATA_NOT_FOUND', {}, null, "คุณไม่สิทธิดำเนินการ")
}
}🔴 CORR-4 — role.accessRole shape ไม่มี validation
บรรทัด 55 — userResult[0].role.accessRole — อ่าน property accessRole จาก JSON blob ที่เก็บใน TEXT column
role: {
type: Sequelize.TEXT, allowNull: false,
get: function () { return JSON.parse(this.getDataValue('role')) },
set: function (value) { this.setDataValue('role', JSON.stringify(value)) }
},Users.role เป็น TEXT column ที่ store JSON string — โครงสร้างที่คาดหวังคือ { accessRole: [roleId1, roleId2, ...] } แต่ ไม่มี schema validation ใด ๆ ถ้า JSON มี shape ผิด (เช่น { roles: [...] } หรือ { accessRole: "ADMIN" } string แทน array) ผลลัพธ์คือ
userResult[0].role.accessRoleเป็นundefinedหรือ type ผิด- ส่งต่อไป
where: { roleId: undefined }ใน Sequelize RoleAccess.findByConditionsอาจ return[](deny) หรือ match all (allow) ขึ้นกับ Sequelize behavior ตอน handle undefined
🔴 silent mis-authorization scenario
| JSON shape ของ Users.role | ผลลัพธ์ |
|---|---|
{ accessRole: ['uuid-admin'] } | ทำงานถูกต้อง |
{ accessRole: 'ADMIN' } (string) | Sequelize WHERE อาจ match ผิด |
{ roles: ['uuid-admin'] } (key ผิด) | accessRole = undefined → ขึ้นกับ Sequelize |
{} | accessRole = undefined → silent deny หรือ silent allow |
| Malformed JSON | JSON.parse throw → catch → DATA_NOT_FOUND |
กรณีที่น่ากลัวที่สุด — ถ้า JSON เก็บ role label ('ADMIN') แทน roleId UUID และ RoleAccess.findByConditions เกิด match role label โดยตรง RBAC จะ authorize ผิดโดยไม่มี error
🟡 บรรทัด 53 — commented-out code เป็น evidence ของ design drift
// const accessableMenu = MENUS_BACK_OFFICE.filter(menu => accessMenu.includes(menu))บรรทัดที่ comment ไว้แสดงว่าก่อนหน้านี้ระบบใช้ MENUS_BACK_OFFICE constant (จาก config.js:159-370) เป็น source of truth แต่เปลี่ยนไปใช้ RoleAccess table — สอง path ไม่ตกลงกันว่า "accessRole" หมายถึงอะไร ความคลุมเครือนี้คือ root cause ของ CORR-4
🟡 บรรทัด 54 — variable shadowing
const accessRole = await RoleAccess.findByConditions({ where: { roleId: userResult[0].role.accessRole } })accessRole ชื่อเดียวกับ parameter ของ checkMenuAuth หาก refactor โดยไม่ระวังจะสับสนว่าอ้างถึงตัวแปรไหน
แหล่งข้อมูลที่กระจายอยู่ 3 ที่
config.js (constants)
├── ACCESS_USER = ['USER'] ← Gate 1 compare
├── ACCESS_BACK_OFFICE = ['SUPER_ADMIN', 'ADMIN', 'STAFF'] ← Gate 1 compare
├── API_DEVICE_KEY (7 prod + 7 UAT keys) ← APP_TYPE resolve
└── MENUS_BACK_OFFICE (30 รายการ, หลายบรรทัด commented) ← (ไม่ได้ใช้แล้ว)
MariaDB vtrc-centralize
├── Users.role (TEXT = JSON string) ← Gate 1 + Gate 2 อ่าน
└── Role.roleName, Role.roleId ← Gate 1 query
MariaDB vtrc
└── RoleAccess (roleId, nameEN, nameTH) ← Gate 2 queryไม่มี single source of truth สำหรับ (role × menu × action) — ทำให้ test coverage แทบเป็นไปไม่ได้
วิธีแก้ CORR-4
| ขั้น | Action |
|---|---|
| 1 | สร้าง typed RoleGrant entity — { roleId, menuKey, action } |
| 2 | ใช้ Casbin policy ที่มี policy test matrix ครอบคลุมทุก (role × menu × action) |
| 3 | Cache permission set ใน Redis (TTL = session TTL) แก้ N+1 ไปในตัว |
| 4 | Validate JSON shape ที่ write path (เช่น JSON schema validator) |
| 5 | ลบ commented-out code + variable shadowing |
ใน Go target (Volume 15) จะใช้ Casbin + Postgres + policy test matrix
ตารารางสรุปช่องโหว่ในบทนี้
| # | ปัญหา | Severity | File:line |
|---|---|---|---|
| 1 | ไม่มี default-deny | 🟡 High | directives/auth.js (entire) |
| 2 | N+1 RBAC query (PERF-4) | 🟡 High | session.js:126-153, 50-70 |
| 3 | CORR-4 role.accessRole shape ไม่ validated | 🔴 Critical | session.js:55 vs :130-148 |
| 4 | commented-out design drift | 🟢 Medium | session.js:53 |
| 5 | variable shadowing | 🟢 Medium | session.js:54 |
| 6 | isActive: 1 BOOLEAN compare | 🟢 Medium | session.js:131-133 |
อ่านต่อ → 9.5 Credentials + secrets