Phase 4 — Business Logic & Access Control (RBAC)
⚠️ SUPERSEDED / ถูกแทนที่แล้ว — This audit snapshot is not authoritative.
อย่าใช้ตัดสินใจ operational โดยไม่ verify โค้ด / Do not use for operational decisions without verifying code.Authoritative docs: VitePress volumes (
docs/volume-00…docs/volume-16) ·docs/audits/docs-verify-2026-07-11-delta-vol2-pass.md·docs/volume-17-appendices/08-changelog-errata.mdKnown stale claims (historical): แผนภาพ time-attendance ถูกแก้แล้ว (2026-07-11) ให้ชี้ cu-central; ส่วนอื่นของไฟล์นี้อาจยังคลาด — ใช้ VitePress เป็นหลัก
i3 Gateway Knowledge Base · VTRC Platform Evidence basis:
vtrc-api/api/src/config.js(device keys, role arrays, menus),directives/auth.js,lib/repositories/{auth,session}/*.js,lib/controllers/{auth,hrmi,withdraw}/*.js, GraphQL typeDefs (role.js,roleAccess.js,authentication.js,leave.js,wdHospital.js),models/core/{session,role,leave,withdraw}/*.js. Modernization target framing: Go (Hexagonal / DDD) for backends · Nuxt 3 (Feature-Sliced Design) for frontends.
4.1 Authentication & Identity Model
VTRC uses a multi-layer auth model with three orthogonal axes:
| Axis | Values | Source |
|---|---|---|
Device class (APP_TYPE) | WEB, MOBILE, WEB_ADMIN, INTEGRATE_APP (INFOMA, CONICLE), TWOWAY, MEETING | config.js:41-148 — device-key registry |
| Role (RBAC) | USER, SUPER_ADMIN, ADMIN, STAFF (backoffice triad) | config.js:150-151 — ACCESS_BACK_OFFICE / ACCESS_USER |
| Menu/Action (ABAC overlay) | ~32 menus (news, hospitalHr, hospitalPaid, Leave, Delegate, DocFound, LeaveStudies, workforceOrg, …) | config.js:159-370 — MENUS_BACK_OFFICE |
Auth header contract
Every GraphQL request from the frontends carries two headers:
apiKey: <device-key UUID> ← maps 1:1 to APP_TYPE
Authorization: Bearer <jwt> ← signed with JWT_SECRET, TTL 15mThe apiKey is the first gate (vtrc-api/api/src/index.js:33-38) — if it is not in API_DEVICE_KEY[API_ACCESS], the request is rejected with ACCESS_KEY_INVALID before the JWT is even examined.
JWT configuration (hardcoded defaults)
secret: JWT_SECRET (default 'secret' — config.js:12) ⚠️ OVERWRITTEN BY ENV IN PROD
issuer: 'redcross.or.th:vtrc' (config.js:39)
ttl: '15m' (JWT_EXP, config.js:38)
algorithm: HS256 (jsonwebtoken default)
jwtid: random uuid per session (auth.js:152)
payload: { id: sessionId, uid: userId }4.2 RBAC Matrix — Who Holds What Permissions
4.2.1 Roles and their scopes
| Role | Who | Can access backoffice? | Can access end-user app? | Source |
|---|---|---|---|---|
USER | Regular Red Cross employee | ❌ | ✅ (web + mobile) | config.js:151, typeDefs/*.js @auth(accessRole: ["USER"]) |
STAFF | HR staff (backoffice) | ✅ | ❌ | config.js:150 |
ADMIN | HR admin (backoffice) | ✅ | ❌ | config.js:150 |
SUPER_ADMIN | System owner (backoffice) | ✅ | ❌ | config.js:150 |
The
Roletable (models/core/role/role.js) stores additional custom roles created viacreateRolemutation, but the four canonical roles above are the system floor — declared as JS constants, not DB rows.
4.2.2 The @auth directive — enforcement matrix
The directive (directives/auth.js:10-49) wraps every protected field. Two arguments:
accessRole: [String]— required role(s). AND-combined with menu check.accessMenu: [String]— optional menu keys. If present, user's role-access must include at least one.
| Operation class | accessRole | accessMenu | Example field |
|---|---|---|---|
| End-user Query (payslip, tax, leave) | ["USER"] | (none) | fetchSlip, timeAttendanceByDate |
| End-user Mutation (create leave, withdraw) | ["USER"] | (none) | (in leave.js, wdHospital.js) |
| Backoffice Query (list roles, menus) | ["SUPER_ADMIN","ADMIN","STAFF"] | (none) | fetchRoleBackoffice, fetchMenuByRole |
| Backoffice Mutation (CRUD roles) | ["SUPER_ADMIN","ADMIN","STAFF"] | (none) | createRole, updateRole, destroyRole |
| Backoffice Mutation (CRUD role-access) | ["SUPER_ADMIN","ADMIN","STAFF"] | (none) | createRoleAccess |
| Public (login, captcha, refresh, forceLoging) | (none — no @auth) | (none) | login, refreshToken, generateCaptcha, forceLoging |
4.2.3 Menu-Access overlay (ABAC)
checkMenuAuth (lib/repositories/session/session.js:50-70) performs an additional menu-level check when accessMenu is present:
- Look up
Users.role(the JSON blob). - Read
role.accessRolearray (the user'sroleIds). - Query
RoleAccessrows for thoseroleIds. - Intersect with the
accessMenuargument. - If empty →
TOKEN_INVALIDwith Thai message "คุณไม่สิทธิดำเนินการ" (you have no permission).
⚠️ RBAC logic bug (in code, not exploited):
getCurrentRoleUser(session.js:126-153) matchesRole.roleNameagainstaccessRole, butcheckMenuAuth(session.js:55) readsuserResult[0].role.accessRole. TheUser.roleJSON stores roleIds (fromcreateSession'scurrentUserRole[0]which is a roleId), sorole.accessRoleis undefined on the JSON. The check appears to rely on Sequelize lazy-loading theRoleassociation. This is a fragile, dual-source-of-truth RBAC that must be unified in the Go rewrite.
4.2.4 Device-Key Registry (inlined in source — config.js:41-148)
flowchart LR
subgraph PROD["prod keys (7)"]
WEB_P["VTRC-WEB<br/>APP_TYPE: WEB"]
MOB_P["VTRC-MOBILE<br/>APP_TYPE: MOBILE"]
BO_P["VTRC-BACKOFFICE<br/>APP_TYPE: WEB_ADMIN"]
INFOMA["INFOMA<br/>APP_TYPE: INTEGRATE_APP"]
CONI["CONICLE<br/>APP_TYPE: INTEGRATE_APP"]
TWOWAY["VTRC-TWOWAY<br/>APP_TYPE: TWOWAY"]
MEET["VTRC-MEETING<br/>APP_TYPE: MEETING"]
end
subgraph UAT["uat keys (10)"]
WEB_U["...-UAT variants"]
LOAD1["LOAD_TEST_MOBLIE"]
LOAD2["LOAD_TEST_BACKOFFICE"]
LOAD3["LOAD_TEST_WEB"]
end
PROD --> GATEWAY["vtrc-api context()<br/>apiKey header → APP_TYPE"]
UAT --> GATEWAYCritical supply-chain risk: these production device keys are committed in cleartext to the repository (config.js:42-85). Anyone with repo read access can impersonate any of the 7 production apps. The key values are plain UUIDs transmitted as apiKey header on every request — they are not rotates, not hashes, and the lookup is a linear filter (index.js:34). See Phase 5 entry SEC-3.
4.3 Session Lifecycle (Reverse-Engineered)
stateDiagram-v2
[*] --> CaptchaIssued: generateCaptcha\n(captchaUid + 5min TTL)
CaptchaIssued --> LoginSession: verifyEmpCodeLogin\n(empCode, captcha, org)\n→ returns lsid + 15min TTL
LoginSession --> Authenticated: login(lsid, password,...)\n→ {accessToken 15m, refreshToken}
LoginSession --> Destroyed: usingCount > 3 → AUTH_LIMIT\nor expireDate passed → SESSION_EXPIRED
Authenticated --> Authenticated: refreshToken on TOKEN_EXPIRED\n(same sessionId, new jwtId)
Authenticated --> Destroyed: logout / force-login elsewhere\nor expireDate > 1 day
Destroyed --> [*]
note right of Authenticated
Session row fields:
- expireDate = now + 1 day
- notificationExpireDate = now + 31 days
- client = WEB|MOBILE|WEB_ADMIN
- tokenCD = Chula SSO token
- userSession.jwtId = uuid (rotated on refresh)
end noteHardcoded timing rules (from auth.js:133-134,234-238 + loginSession.js:43)
| Rule | Value | Source |
|---|---|---|
Login session (LoginSession) TTL | 15 minutes | loginSession.js:43 — setMinutes(+15) |
| Captcha session TTL | 5 minutes | user.js:219,247 — setMinutes(+5) |
Authenticated session (Session) TTL | 1 day | auth.js:133 — dateDayAfter(1) |
| Notification token TTL | 31 days | auth.js:134 — dateDayAfter(31) |
| JWT access-token TTL | 15 minutes | config.js:38 — JWT_EXP='15m' |
| Refresh-token material | SHA-384 of uuidv4 | auth.js:293 — EncryptAll(uuidv4(), "sha384") |
| Captcha attempt limit | 3 uses (4th triggers AUTH_LIMIT) | loginSession.js:208 — usingCount > 3 |
| Signed-PDF URL validity | 3600 seconds (1 hour) | routes.js:55,104,208,258,310 — timeout <= 3600 |
4.4 Hardcoded Business Rules
The platform encodes Red Cross HR policy in three places: (a) config.js constant tables, (b) Models.* ENUMs, (c) inline if checks inside controllers. Below is the extracted ruleset with exact evidence.
4.4.1 Leave — per-type day limits and time windows
Source: vtrc-api/api/src/lib/controllers/hrmi/hrmi.js:1295-1319 (leave creation path).
| Rule | Logic | Evidence |
|---|---|---|
| Per-leave-type maximum days per request | if (maxLeaveDay && args.leaveDay > maxLeaveDay) → rejectMessage: "<type> ไม่เกิน <N> วัน ต่อครั้ง" | hrmi.js:1304-1308 |
| Per-leave-type minimum days per request | if (minLeaveDay && args.leaveDay < minLeaveDay) → rejectMessage: "ต้องลาอย่างน้อย <N> วัน" | hrmi.js:1309-1310 |
| Advance-booking window | Leave start must be ≤ now + earlyLeaveDay | hrmi.js:1319 — after5day = moment().add(earlyLeaveDay, 'day') |
| Retroactive-booking window | Leave start must be ≥ now - backLeaveDay | hrmi.js:1317 — back5day = moment().subtract(backLeaveDay, 'day') |
| Admin bypass | args.adminCanPass === true skips all 4 checks above | hrmi.js:1302 — && !args.adminCanPass |
| Special org-unit rule | Org 0734* (likely HR HQ) admins are exempt from the per-type limit gate (treated like a regular client) | hrmi.js:1302 — adminDetail.orgUnitCode.substring(0,4) !== '0734' |
| Per-employee leave balance | leaveUsed vs leaveDayAll tracked on LeaveDocument; leaveTotalUse aggregated | models/core/leave/leave.js:128-139 |
4.4.2 Leave — approval chain (multi-level)
Source: hrmi.js:60-108 (getApprover), hrmi.js:110-180 (getLeaveApprover).
flowchart TD
REQ[Create leave request] --> L1[Level 1 approver\nimmediate manager]
L1 -->|approve| L2[Level 2 approver\ndepartment head]
L2 -->|approve| L3[Level N approver\n...up to N levels from HRMI]
L3 -->|approve| DONE[status: APPROVE]
L1 -->|reject| REJ[status: REJECT]
L2 -->|reject| REJ
L3 -->|reject| REJ
L1 -->|moreinfo| MI[status: MOREINFO\n→ returns to requester]
L2 -->|moreinfo| MI
MI --> REQ
REQ --> CANCEL[status: CANCEL\nby requester]
CANCEL --> PEDDING[status: PEDDING_CANCLE\n⚠️ typo in ENUM]
style PEDDING fill:#fde2e1,stroke:#c0392bHardcoded approval rules:
| Rule | Logic | Evidence |
|---|---|---|
| Multi-level chain sourced from HRMI | Approver list is fetched from CU/HRMI via hrmiGetLeaveApproverCD/hrmiGetApproverCD — not stored in VTRC | hrmi.js:65,122 |
| One approver per level (group-of-many) | Approvers are grouped by level; within a level, multiple users may be selectable | hrmi.js:81-99 |
| Sequential level escalation | LeaveDocument.level tracks current level (default 1); orderNo tracks sequence | models/core/leave/leave.js:62-66 |
| Each approver recorded | Approver table rows with orderNo, level, status, noteApprove | models/core/withdraw/approver.js (shared by leave + withdraw) |
4.4.3 Leave — state machine (the typos included)
From models/core/leave/leave.js:59 ENUM:
| State | Meaning | Notes |
|---|---|---|
PEDDING_CANCLE | Pending-cancellation request | ⚠️ Typo: should be PENDING_CANCEL. Permanent — baked into DB. |
PENDING | Awaiting level-1 approval | |
MOREINFO | Approver needs more info from requester | |
APPROVE | Fully approved through all levels | |
REJECT | Rejected at some level | |
CANCEL | Cancelled by requester |
4.4.4 Welfare Withdrawal (Hospital) — the 13-state machine
From models/core/withdraw/hospital.js:95:
stateDiagram-v2
[*] --> DRAFT: employee saves draft
DRAFT --> PENDING: submit for approval
PENDING --> MOREINFO: approver needs detail
MOREINFO --> PENDING: employee re-submits
PENDING --> APPROVE: all levels approve
PENDING --> REJECT: any level rejects
PENDING --> EDIT: admin edits (org 0734*)
EDIT --> PENDING
APPROVE --> PAID: bank transfer SUCCESS (KBANK)
APPROVE --> PAIDCASH: cash payout
APPROVE --> FAILED: bank transfer FAILED
FAILED --> SUCCESS: retry succeeds
FAILED --> CANCELDOC: cancelled doc
PAID --> [*]
PAIDCASH --> [*]
SUCCESS --> [*]
REJECT --> [*]
DRAFT --> CANCEL: employee cancels
PENDING --> CANCEL
CANCEL --> [*]
CANCELDOC --> [*]Hardcoded withdrawal rules:
| Rule | Logic | Evidence |
|---|---|---|
| Two payout rails | bank ENUM('KBANK','OTHER') — KBANK = automated bank export; OTHER = manual | models/core/withdraw/hospital.js:217-219 |
| KBANK status mapping | K-Cash response codes 6,7,8,10 → FAILED; 9 → SUCCESS | config.js:488-518 — statusKcash |
| Money precision | DECIMAL(12,2) ✅ on all totalWithdraw/totalCurrentWithdraw/previousCurrentWithdraw | hospital.js:87,142-148 |
| Aggregated lifetime cap tracking | previousCurrentWithdraw + totalCurrentWithdraw imply cumulative-balance enforcement for the same sickness/employee | hospital.js:142-148 |
| Hospital-bank-account required | WDHospital.belongsTo(HospitalBankAccount) — payouts go to hospital accounts, not employee accounts | hospital.js:258 |
| Approver chain shared with Leave | Same Approver model (orderNo, level, status) | models/core/withdraw/approver.js |
| Master-approver config | MasterApprover keyed by orgCode — each org has its own approver ladder | hospital.js:251 |
4.4.5 Identity — Relation codes (claimant relationship to employee)
From config.js:392-429 (relationHrmi):
| Code | Relationship (TH) | Meaning |
|---|---|---|
00 | พนักงาน | Employee (self-claim) |
10 | บิดา | Father |
20 | มารดา | Mother |
70 | คู่สมรส | Spouse |
81-85 | บุตรคนที่ 1-5 | Child #1 through #5 |
Maximum 5 children claimable — a hardcoded welfare-policy ceiling.
4.4.6 HRMI status code mapping (legacy → VTRC)
From config.js:430-487:
| HRMI code | Internal status | Thai label |
|---|---|---|
W | PENDING | รออนุมัติ |
Y | APPROVE | อนุมัติ |
N | REJECT | ไม่อนุมัติ |
C | CANCEL | ยกเลิก |
CR | CREATE | สร้างเอกสาร |
D | DRAF ⚠️ (typo for DRAFT) | บันทึกร่าง |
A | ALL | ทั้งหมด (filter only) |
4.4.7 Other hardcoded rules
| Rule | Value | Evidence |
|---|---|---|
| Post upload limit | Hard cap (rejects with OUT_OF_LIMIT) | lib/controllers/post/post.js:39 |
| Excel upload batch size | 100 files max | routes.js:45 — maxCount: 100 |
| GraphQL upload limits | maxFileSize: 20MB, maxFiles: 100 | index.js:25 |
| PDF deletion grace | 5 seconds after sendFile | routes.js:79-82 — setTimeout(deletePDF, 5000) |
| PDF password source | Employee's national ID (identityCard) or a derived passwordPDF | routes.js:73 |
| Default upload-file body limit | 50 MB (Express + Apollo) | index.js:87,92-93 |
| CORS allowed origins | 5 explicit + localhost:3000 | index.js:77-85 |
linkUpdateApplication | IOS, ANDROID (Play Store), WEB | config.js:371-384 |
| Conicle allowed pages | MyLibrary, LearningRequest, Home only | config.js:155 — CONICLE_PAGE |
| HR special role IDs | 2 UUIDs (305A814A-..., 5A764C0F-...) | config.js:154 — KEY_HR |
| CU re-structure key | 'CU-RE-STRUCTURE-ID-02' | config.js:152 — KEY_CU_H |
| Plasma key | '47837e2d-7788-4a1a-8e24-a90abf6742f4' | config.js:153 — KEY_PLASMA |
4.5 The Highest-Stakes Transaction — Clock-in / Clock-out Data Flow
Although VTRC does not originate clock-in/out events (the upstream HRMI system does), the time-attendance read flow is the most operationally critical transaction because (a) it touches all 4 services, (b) it crosses 2 database engines, (c) it carries PII (empCode + national ID), and (d) it underpins payroll. The sequence below is the verified path for
Query.timeAttendanceByDate.
sequenceDiagram
autonumber
actor U as Employee
participant W as vtrc-web (React)
participant N as Nginx
participant A as vtrc-api<br/>(Apollo + Express)
participant CU as cu-central-api<br/>(GraphQL + LDAP + MSSQL)
participant M as MSSQL<br/>dbHRMI_Center
participant AD as Active Directory
Note over U,M: PHASE 1 — AUTHENTICATION (one-time per session)
U->>W: open app, enter empCode + password
W->>N: POST /api/graphql { verifyEmpCodeLogin, generateCaptcha }
N->>A: forward (apiKey=WEB)
A->>A: context() validates apiKey in API_DEVICE_KEY
A-->>W: { lsid, captchaSvg }
W->>N: POST /api/graphql { login(lsid, password,...) }
N->>A: forward
A->>A: checkLoginSession
A->>CU: REST END_POINT_CENTRALIZE_AUTH /signin<br/>(via centralize/controllers/auth.js)
CU->>AD: ldapjs bind(empCode, password)
AD-->>CU: bind OK
CU-->>A: { access token / profile }
A->>A: generateToken → jwt.sign (JWT_EXP default 15m)
A-->>W: { accessToken, refreshToken }
W->>W: store tokens
Note over U,M: PHASE 2 — TIME-ATTENDANCE READ
U->>W: tap Time Attendance
W->>N: POST /api/graphql<br/>Headers: apiKey + Authorization Bearer jwt<br/>Body: timeAttendanceByDate
N->>A: forward
A->>A: @auth + checkSession
A->>CU: GraphQL END_POINT_CENTRALIZE<br/>(centralize/controllers/timeAttendance.js)
CU->>M: Sequelize / HRMI query by EmpCode + date
M-->>CU: time-event rows
CU-->>A: GraphQL payload
A-->>W: TimeAttendanceResponse
W-->>U: render clock-in/out listแก้ไข 2026-07-11: แผนภาพเดิมชี้อ่านไป NestJS + TypeORM + Redis cache บน path live — แก้ให้ตรงโค้ด: auth =
END_POINT_CENTRALIZE_AUTH, HR read =END_POINT_CENTRALIZE→ cu-central-api (ไม่ใช่ NestJS)
Critical observations on the clock-in/out flow
- Five services touched, two DB engines, one LDAP — every time-attendance read is a 5-hop chain. Latency budget is opaque; no distributed tracing.
fetchPolicy: 'no-cache'on the frontend (vtrc-web/src/config/apollo.js:173) means every tab switch re-hits the network — the only caching is server-side Redis.- Token refresh uses a side-channel (
apolloFetchatapollo.js:74), not the main Apollo client — becauseerrorLinkruns inside the client. This is the most fragile auth path: if the refresh fails, the user is silently logged out. - The
@authdirective runs on every protected field — including each list item if the schema is misconfigured. Watch for N×auth-query N+1. - No rate limiting on
timeAttendanceByDate— a malicious token could hammer MSSQL through the bridge. - PII surface:
empCodeflows through every layer and into Redis keys (ta:{empCode}:{date}) and logs.
4.6 Modernization Mapping — RBAC & Business Rules → Go (Hex/DDD)
| Current | Issue | Target (Go) |
|---|---|---|
Device keys in config.js cleartext | SEC-3 (Phase 5) | Secret manager (Vault/AWS SM); keys are hashed (Argon2id), compared in constant time |
apiKey linear filter lookup | O(N) per request | Pre-computed map; or signed JWT that contains APP_TYPE claim |
@auth(accessRole, accessMenu) directive | Apollo-v2-only; bypassable via REST | Casbin policy engine or OPA — same rules enforced at HTTP middleware (chi/echo) before GraphQL/REST dispatch |
JSON-blob Users.role | No relational integrity | user_roles join table; canonical Role aggregate in identity bounded context |
checkMenuAuth dual-source bug | Logic inconsistency | Single policy source; tests for every (role × menu) pair |
getCurrentRoleUser matches roleName; checkMenuAuth reads accessRole | Inconsistent | Replace with single User.HasPermission(menu, action) method on the aggregate root |
Hardcoded maxLeaveDay/minLeaveDay per type | Policy frozen in filterArr[0] | LeavePolicy value object loaded from a leave_policies table; editable by HR without deploy |
orgUnitCode.substring(0,4) !== '0734' magic string | Brittle org rule | OrgUnit.IsHRHQ boolean field; configurable |
adminCanPass boolean arg | Client-controlled bypass risk | Server-side User.CanBypassLeaveLimits() derived from role, never from client input |
| 13-state WDHospital ENUM | Typos (DRAF, PEDDING_CANCLE) baked in | Canonical state machine in welfare domain; status is a typed enum; legacy strings mapped at ACL boundary |
relationHrmi max 5 children | Hardcoded cap | RelationPolicy with configurable child ceiling |
KBANK status mapping in config.js | Vendor-coupled | BankGateway port; KBANK adapter maps codes; new banks = new adapter |
execSync(qpdf) for encrypted PDFs | SEC-1, PERF-1 | document-renderer service (Go + chromedp); PDF password via streaming, no shell |
In-process node-schedule crons | No HA | asynq + Redis (Go) with distributed locks |
fetchPolicy: 'no-cache' | Perf killer | Nuxt 3 useFetch + stale-while-revalidate; normalized cache |
Phase 4 complete. Proceed to 05-tech-debt-register.md — the consolidated technical-debt and modernization register.