Skip to content

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  │
└─────────────────────────────────────────────────────────────┘
แกนSourceGate ที่เช็ค
APP_TYPE (device class)apiKey header → API_DEVICE_KEY lookupใน signInApp (login เท่านั้น)
Role (RBAC)Users.role JSON blob + Role tablegetCurrentRoleUser
Menu (ABAC overlay)RoleAccess tablecheckMenuAuth

ปัญหา — 3 แกนนี้กระจายอยู่ใน 3 ที่ (config, JSON blob, ตารางแยก) ไม่มี single source of truth


@auth directive — ทำงานอย่างไร

10:49:vtrc-api/api/src/directives/auth.js
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 ขั้น

  1. L19-21 — token presence
  2. L23 — decode เพื่อ pull jti
  3. L26-31 — verify signature + expiry + issuer + jti
  4. L43checkSession (role + session)
  5. L44checkMenuAuth (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)

126:153:vtrc-api/api/src/lib/repositories/session/session.js
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 คือ

  1. L127-136 — query #1 load Role ที่ roleName match directive arg (['SUPER_ADMIN','ADMIN']) + isActive: 1
  2. L140-145 — query #2 load Users.role JSON blob ของ user
  3. L148 — read userRole.role.accessRole — คาดหวังว่าเป็น array ของ roleId UUID
  4. 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

131:133:vtrc-api/api/src/lib/repositories/session/session.js
      isActive: 1

isActive เป็น Sequelize.BOOLEAN แต่ compare กับ integer 1 ใน MariaDB มัน coerce ได้ แต่เป็น code smell ที่บ่งบอกว่าไม่มี strict type thinking


Gate 2 · checkMenuAuth (CORR-4 Critical)

50:70:vtrc-api/api/src/lib/repositories/session/session.js
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

19:28:vtrc-api/api/src/models/centralize/user/user.js
        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 JSONJSON.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

53:53:vtrc-api/api/src/lib/repositories/session/session.js
    // 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

54:54:vtrc-api/api/src/lib/repositories/session/session.js
    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)
3Cache permission set ใน Redis (TTL = session TTL) แก้ N+1 ไปในตัว
4Validate JSON shape ที่ write path (เช่น JSON schema validator)
5ลบ commented-out code + variable shadowing

ใน Go target (Volume 15) จะใช้ Casbin + Postgres + policy test matrix


ตารารางสรุปช่องโหว่ในบทนี้

#ปัญหาSeverityFile:line
1ไม่มี default-deny🟡 Highdirectives/auth.js (entire)
2N+1 RBAC query (PERF-4)🟡 Highsession.js:126-153, 50-70
3CORR-4 role.accessRole shape ไม่ validated🔴 Criticalsession.js:55 vs :130-148
4commented-out design drift🟢 Mediumsession.js:53
5variable shadowing🟢 Mediumsession.js:54
6isActive: 1 BOOLEAN compare🟢 Mediumsession.js:131-133

อ่านต่อ → 9.5 Credentials + secrets