9.5 · ERD — Identity & Access
โดเมน Identity คือส่วนที่จัดการว่า "ใคร login เข้ามา, มี session อะไรบ้าง, มี role อะไร" เป็น foundation ของทุก domain อื่น — ถ้า data model ตรงนี้ผิด ทุก flow พังตาม
บทนี้ map ตารางทั้งหมด, relation, และจุดที่ design ไม่ relational จริง (RBAC เก็บใน JSON)
Tables ใน domain
| ตาราง | PK | schema | หน้าที่ |
|---|---|---|---|
Users | userId UUID | vtrc-centralize (db2) | identity หลัก + refId map ไป HRMI |
UserDivision | userDivisionId UUID | vtrc-centralize (db2) | division/level ของ user (multi-profile) |
Sessions | sessionId UUID | vtrc (db) | refresh token + current session state |
LoginSession | UUID | vtrc | pre-login / login-flow session (เก็บ empCode ใน JSON — ใช้ตอน sign-in) |
CaptchaSession | UUID | vtrc | captcha challenge state |
AuthSession | UUID | vtrc | mobile bypass session (publicKey + userId — ไม่ใช่ emp-code verify) |
Roles | roleId UUID | vtrc | role definition |
RoleAccess | roleAccessId UUID | vtrc | menu access per role |
ERD — ASCII vertical flow
vtrc-centralize schema (db2)
┌─────────────────────────────────────────────────────────────┐
│ │
│ Users │
│ ├── userId (PK, UUID) │
│ ├── personId (UUID) │
│ ├── param (STRING — empCode/link key) │
│ ├── role (TEXT, JSON-encoded array via getter/setter) │
│ ├── token (TEXT — current JWT) │
│ ├── refreshToken (TEXT) │
│ ├── clientInfo (TEXT) │
│ ├── refId (TEXT — link to HRMI EmpCode) │
│ └── sourceDb (STRING — HRMI SourceDB id เช่น `dbHRMI_RC_B_rep`, `dbHRMI_CU_SC_rep`; ไม่ใช่ enum `'vtrc'\|'nbc'`) │
│ │ │
│ │ hasMany │
│ ▼ │
│ UserDivision │
│ ├── userDivisionId (PK, UUID) │
│ ├── userId (FK → Users.userId) │
│ ├── level (STRING) │
│ ├── idLevel (STRING — UUID ของ division ฝั่ง HRMI) │
│ ├── profileKey (STRING) │
│ ├── appId (STRING) │
│ └── profileLevel (ENUM 'MAIN' | 'SUB') │
│ │
└─────────────────────────────────────────────────────────────┘
│
│ (no FK — cross-schema, join ผ่าน refId/EmpCode)
▼
vtrc schema (db)
┌─────────────────────────────────────────────────────────────┐
│ │
│ Sessions │
│ ├── sessionId (PK, UUID) │
│ ├── userId (UUID — logical FK, ไม่ใช่ DB constraint) │
│ ├── refreshToken (STRING) │
│ ├── userSession (TEXT, JSON-encoded) │
│ ├── role (STRING) │
│ ├── expireDate (DATE) │
│ ├── notificationExpireDate (DATE) │
│ ├── client (ENUM 'WEB' | 'MOBILE' | 'WEB_ADMIN') │
│ ├── currentProfile (TEXT) │
│ ├── tokenCD (TEXT — Chula SSO token cached) │
│ └── refreshTokenCD (STRING) │
│ │
│ Roles │
│ ├── roleId (PK, UUID) │
│ ├── roleLabel (STRING) │
│ ├── roleName (STRING) │
│ ├── description (TEXT) │
│ ├── isActive (BOOLEAN) │
│ └── updateBy (UUID) │
│ │ │
│ │ hasMany │
│ ▼ │
│ RoleAccess │
│ ├── roleAccessId (PK, UUID) │
│ ├── roleId (FK → Roles.roleId) │
│ ├── nameTH (STRING) │
│ ├── nameEN (STRING) │
│ ├── menuKey (STRING — เชื่อมกับ menu definition) │
│ └── orderNo (STRING) │
│ │
└─────────────────────────────────────────────────────────────┘Relation definitions (จาก code)
Users → UserDivision
User.associate = (models) => {
models.User.hasMany(models.UserDivision, { as: 'userUserDivision', foreignKey: 'userId', onDelete: 'RESTRICT', onUpdate: 'RESTRICT' })
}one User → many UserDivision (multi-profile user — พนักงานที่มีหลาย position)
Role → RoleAccess
Role.associate = (models) => {
models.Role.hasMany(models.RoleAccess, { as: 'roleAccess', foreignKey: 'roleId', onDelete: 'RESTRICT', onUpdate: 'RESTRICT', hooks: true })
} RoleAccess.associate = (models) => {
models.RoleAccess.belongsTo(models.Role, { as: 'role', foreignKey: 'roleId' })
}one Role → many RoleAccess (แต่ละ role เข้าถึง menu หลายอัน)
Session → User — ไม่มี relation จริง
// associate
Session.associate = (models) => {
}Session.associate ว่างเปล่า — Session ไม่ได้ declare belongsTo User เพราะ Session อยู่ใน vtrc schema ส่วน User อยู่ใน vtrc-centralize schema ที่ Sequelize ไม่ cross-database relation ได้
ผลกระทบ — join Session กับ User ต้องทำสอง query แล้ว merge ใน memory, หรือใช้ raw query cross-schema
RBAC — JSON-encoded ไม่ใช่ relation
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 เก็บเป็น JSON string ใน TEXT column — ไม่ใช่ FK ไปที่ Roles
ตัวอย่างค่าใน DB:
["ADMIN", "STAFF"]ผลกระทบของการเก็บแบบนี้
| ปัญหา | รายละเอียด |
|---|---|
| audit "ใครมี role X" ยาก | ต้อง SELECT * FROM Users แล้ว parse JSON ทุก row ในแอป |
join กับ Roles table ไม่ได้ | ไม่มี FK ทำให้หาชื่อ role ต้อง query สองครั้ง |
| ลบ role แล้วข้อมูลค้าง | ถ้าลบ role STAFF จาก Roles table, Users ที่มี ["STAFF"] ใน JSON ยังคงมีค่าเดิม |
| ไม่ enforce ค่าที่ valid | ใส่ ["ANYTHING"] ได้ — DB ไม่ validate |
| index ไม่ได้ | ไม่สามารถ WHERE role = 'STAFF' ได้โดยตรง |
ทำไมถึงเป็นแบบนี้
เดาว่าทีมอยากให้ user หนึ่งคนมีได้หลาย role (multi-role) โดยไม่ต้องสร้าง join table แยก — เป็น shortcut ที่ทำงานได้แต่ trade-off คือ audit / migration ยาก
Roles กับ RoleAccess table มีอยู่จริง — แต่ใช้แค่ฝั่ง backoffice สำหรับจัดการ menu access ไม่ใช่การ map user → role
ทำไมมี Session table แยก
Session เก็บ state ที่ runtime ต้องการ:
refreshToken— เพื่อ rotate โดยไม่ต้องให้ user login ใหม่userSession(JSON) — cache data ที่ fetch บ่อย เช่น profile, division, เพื่อลด query ไป HRMIclient(ENUM) — แยก WEB / MOBILE / WEB_ADMIN เพื่อ limit จำนวน device per userexpireDate— TTL ของ sessiontokenCD/refreshTokenCD— cache Chula SSO token เพื่อไม่ต้อง re-auth กับ AD ทุกครั้ง
Session เป็น สะพานระหว่าง vtrc กับ Chula — เพราะ SSO token ของ Chula ถูก cache ไว้ที่นี่
Multi-profile user
พนักงานบางคนมีหลาย position (เช่น ทำงานที่กอง A และกอง B) → มีหลาย UserDivision row
// User u123 อาจจะมี:
UserDivision { userDivisionId: 'd1', userId: 'u123', level: '2', idLevel: 'div-A', profileKey: 'p1', profileLevel: 'MAIN' }
UserDivision { userDivisionId: 'd2', userId: 'u123', level: '3', idLevel: 'div-B', profileKey: 'p2', profileLevel: 'SUB' }profileLevel: 'MAIN' คือ profile หลักที่ user จะเห็นตอน login, 'SUB' คือ profile รองที่ user เลือก switch ได้
ผลกระทบ — resolver ที่ query ข้อมูล user ต้องรับ profileKey parameter เพื่อเลือก division context
Cross-schema bridge — ไม่มี FK
vtrc-centralize.Users.userId ←──→ vtrc.Sessions.userId
(logical FK, no DB constraint)
vtrc-centralize.Users.refId ←──→ dbHRMI_Center.Employee.EmpCode
(string mapping, no constraint)Sequelize ไม่ cross-database relation ได้ → ทุก reference ข้าม schema เป็น "logical FK" ที่แอป enforce เอง
ผลกระทบ:
- DB ไม่บังคับ referential integrity → foreign row หาย ข้อมูลนี้ orphan ได้
- ORM ไม่ช่วย → ทุก query ที่ join ข้าม schema ต้อง manual
- migration / cleanup ยาก — ไม่รู้ว่า Session.userId ไหนยัง valid
เคล็ดลับตอนทำงานจริง
ถ้าจะ query "user คนนี้มี role อะไรบ้าง"
อย่า query Users.role โดยตรง — ใช้ Session ปัจจุบันแทน เพราะ Session.role เป็นค่าที่ resolved แล้วจาก user's role JSON + current profile
ถ้าเจอ user ที่หน้า backoffice ไม่แสดง menu
- เช็ก
Roles.isActive— ถ้า role ถูกปิด menu จะหาย - เช็ก
RoleAccess.menuKey— ถ้า menu key ไม่ตรงกับconfig.js#API_MENUSฝั่ง backoffice → menu ไม่ขึ้น
ถ้าเจอ session ที่ userId ไม่ตรงกับ user จริง
Session.userId เป็น logical FK → ถ้า user ถูกลบจาก vtrc-centralize.Users session ยังค้างอยู่ (orphan) ต้อง manual cleanup
อย่า trust Users.role JSON
ค่าใน JSON อาจจะมี role ที่ลบจาก Roles table ไปแล้ว → enforce RBAC ฝั่ง controller ทุกครั้ง อย่าเชื่อ JSON เป็น source of truth