Skip to content

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-00docs/volume-16) · docs/audits/docs-verify-2026-07-11-delta-vol2-pass.md · docs/volume-17-appendices/08-changelog-errata.md

Known 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:

AxisValuesSource
Device class (APP_TYPE)WEB, MOBILE, WEB_ADMIN, INTEGRATE_APP (INFOMA, CONICLE), TWOWAY, MEETINGconfig.js:41-148 — device-key registry
Role (RBAC)USER, SUPER_ADMIN, ADMIN, STAFF (backoffice triad)config.js:150-151ACCESS_BACK_OFFICE / ACCESS_USER
Menu/Action (ABAC overlay)~32 menus (news, hospitalHr, hospitalPaid, Leave, Delegate, DocFound, LeaveStudies, workforceOrg, …)config.js:159-370MENUS_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 15m

The 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)

yaml
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

RoleWhoCan access backoffice?Can access end-user app?Source
USERRegular Red Cross employee✅ (web + mobile)config.js:151, typeDefs/*.js @auth(accessRole: ["USER"])
STAFFHR staff (backoffice)config.js:150
ADMINHR admin (backoffice)config.js:150
SUPER_ADMINSystem owner (backoffice)config.js:150

The Role table (models/core/role/role.js) stores additional custom roles created via createRole mutation, 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 classaccessRoleaccessMenuExample 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:

  1. Look up Users.role (the JSON blob).
  2. Read role.accessRole array (the user's roleIds).
  3. Query RoleAccess rows for those roleIds.
  4. Intersect with the accessMenu argument.
  5. If empty → TOKEN_INVALID with Thai message "คุณไม่สิทธิดำเนินการ" (you have no permission).

⚠️ RBAC logic bug (in code, not exploited): getCurrentRoleUser (session.js:126-153) matches Role.roleName against accessRole, but checkMenuAuth (session.js:55) reads userResult[0].role.accessRole. The User.role JSON stores roleIds (from createSession's currentUserRole[0] which is a roleId), so role.accessRole is undefined on the JSON. The check appears to rely on Sequelize lazy-loading the Role association. 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)

mermaid
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 --> GATEWAY

Critical 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)

mermaid
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 note

Hardcoded timing rules (from auth.js:133-134,234-238 + loginSession.js:43)

RuleValueSource
Login session (LoginSession) TTL15 minutesloginSession.js:43setMinutes(+15)
Captcha session TTL5 minutesuser.js:219,247setMinutes(+5)
Authenticated session (Session) TTL1 dayauth.js:133dateDayAfter(1)
Notification token TTL31 daysauth.js:134dateDayAfter(31)
JWT access-token TTL15 minutesconfig.js:38JWT_EXP='15m'
Refresh-token materialSHA-384 of uuidv4auth.js:293EncryptAll(uuidv4(), "sha384")
Captcha attempt limit3 uses (4th triggers AUTH_LIMIT)loginSession.js:208usingCount > 3
Signed-PDF URL validity3600 seconds (1 hour)routes.js:55,104,208,258,310timeout <= 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).

RuleLogicEvidence
Per-leave-type maximum days per requestif (maxLeaveDay && args.leaveDay > maxLeaveDay) → reject
Message: "<type> ไม่เกิน <N> วัน ต่อครั้ง"
hrmi.js:1304-1308
Per-leave-type minimum days per requestif (minLeaveDay && args.leaveDay < minLeaveDay) → reject
Message: "ต้องลาอย่างน้อย <N> วัน"
hrmi.js:1309-1310
Advance-booking windowLeave start must be ≤ now + earlyLeaveDayhrmi.js:1319after5day = moment().add(earlyLeaveDay, 'day')
Retroactive-booking windowLeave start must be ≥ now - backLeaveDayhrmi.js:1317back5day = moment().subtract(backLeaveDay, 'day')
Admin bypassargs.adminCanPass === true skips all 4 checks abovehrmi.js:1302&& !args.adminCanPass
Special org-unit ruleOrg 0734* (likely HR HQ) admins are exempt from the per-type limit gate (treated like a regular client)hrmi.js:1302adminDetail.orgUnitCode.substring(0,4) !== '0734'
Per-employee leave balanceleaveUsed vs leaveDayAll tracked on LeaveDocument; leaveTotalUse aggregatedmodels/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).

mermaid
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:#c0392b

Hardcoded approval rules:

RuleLogicEvidence
Multi-level chain sourced from HRMIApprover list is fetched from CU/HRMI via hrmiGetLeaveApproverCD/hrmiGetApproverCD — not stored in VTRChrmi.js:65,122
One approver per level (group-of-many)Approvers are grouped by level; within a level, multiple users may be selectablehrmi.js:81-99
Sequential level escalationLeaveDocument.level tracks current level (default 1); orderNo tracks sequencemodels/core/leave/leave.js:62-66
Each approver recordedApprover table rows with orderNo, level, status, noteApprovemodels/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:

StateMeaningNotes
PEDDING_CANCLEPending-cancellation request⚠️ Typo: should be PENDING_CANCEL. Permanent — baked into DB.
PENDINGAwaiting level-1 approval
MOREINFOApprover needs more info from requester
APPROVEFully approved through all levels
REJECTRejected at some level
CANCELCancelled by requester

4.4.4 Welfare Withdrawal (Hospital) — the 13-state machine

From models/core/withdraw/hospital.js:95:

mermaid
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:

RuleLogicEvidence
Two payout railsbank ENUM('KBANK','OTHER') — KBANK = automated bank export; OTHER = manualmodels/core/withdraw/hospital.js:217-219
KBANK status mappingK-Cash response codes 6,7,8,10 → FAILED; 9 → SUCCESSconfig.js:488-518statusKcash
Money precisionDECIMAL(12,2) ✅ on all totalWithdraw/totalCurrentWithdraw/previousCurrentWithdrawhospital.js:87,142-148
Aggregated lifetime cap trackingpreviousCurrentWithdraw + totalCurrentWithdraw imply cumulative-balance enforcement for the same sickness/employeehospital.js:142-148
Hospital-bank-account requiredWDHospital.belongsTo(HospitalBankAccount) — payouts go to hospital accounts, not employee accountshospital.js:258
Approver chain shared with LeaveSame Approver model (orderNo, level, status)models/core/withdraw/approver.js
Master-approver configMasterApprover keyed by orgCode — each org has its own approver ladderhospital.js:251

4.4.5 Identity — Relation codes (claimant relationship to employee)

From config.js:392-429 (relationHrmi):

CodeRelationship (TH)Meaning
00พนักงานEmployee (self-claim)
10บิดาFather
20มารดาMother
70คู่สมรสSpouse
81-85บุตรคนที่ 1-5Child #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 codeInternal statusThai label
WPENDINGรออนุมัติ
YAPPROVEอนุมัติ
NREJECTไม่อนุมัติ
CCANCELยกเลิก
CRCREATEสร้างเอกสาร
DDRAF ⚠️ (typo for DRAFT)บันทึกร่าง
AALLทั้งหมด (filter only)

4.4.7 Other hardcoded rules

RuleValueEvidence
Post upload limitHard cap (rejects with OUT_OF_LIMIT)lib/controllers/post/post.js:39
Excel upload batch size100 files maxroutes.js:45maxCount: 100
GraphQL upload limitsmaxFileSize: 20MB, maxFiles: 100index.js:25
PDF deletion grace5 seconds after sendFileroutes.js:79-82setTimeout(deletePDF, 5000)
PDF password sourceEmployee's national ID (identityCard) or a derived passwordPDFroutes.js:73
Default upload-file body limit50 MB (Express + Apollo)index.js:87,92-93
CORS allowed origins5 explicit + localhost:3000index.js:77-85
linkUpdateApplicationIOS, ANDROID (Play Store), WEBconfig.js:371-384
Conicle allowed pagesMyLibrary, LearningRequest, Home onlyconfig.js:155CONICLE_PAGE
HR special role IDs2 UUIDs (305A814A-..., 5A764C0F-...)config.js:154KEY_HR
CU re-structure key'CU-RE-STRUCTURE-ID-02'config.js:152KEY_CU_H
Plasma key'47837e2d-7788-4a1a-8e24-a90abf6742f4'config.js:153KEY_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.

mermaid
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_CENTRALIZEcu-central-api (ไม่ใช่ NestJS)

Critical observations on the clock-in/out flow

  1. Five services touched, two DB engines, one LDAP — every time-attendance read is a 5-hop chain. Latency budget is opaque; no distributed tracing.
  2. 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.
  3. Token refresh uses a side-channel (apolloFetch at apollo.js:74), not the main Apollo client — because errorLink runs inside the client. This is the most fragile auth path: if the refresh fails, the user is silently logged out.
  4. The @auth directive runs on every protected field — including each list item if the schema is misconfigured. Watch for N×auth-query N+1.
  5. No rate limiting on timeAttendanceByDate — a malicious token could hammer MSSQL through the bridge.
  6. PII surface: empCode flows through every layer and into Redis keys (ta:{empCode}:{date}) and logs.

4.6 Modernization Mapping — RBAC & Business Rules → Go (Hex/DDD)

CurrentIssueTarget (Go)
Device keys in config.js cleartextSEC-3 (Phase 5)Secret manager (Vault/AWS SM); keys are hashed (Argon2id), compared in constant time
apiKey linear filter lookupO(N) per requestPre-computed map; or signed JWT that contains APP_TYPE claim
@auth(accessRole, accessMenu) directiveApollo-v2-only; bypassable via RESTCasbin policy engine or OPA — same rules enforced at HTTP middleware (chi/echo) before GraphQL/REST dispatch
JSON-blob Users.roleNo relational integrityuser_roles join table; canonical Role aggregate in identity bounded context
checkMenuAuth dual-source bugLogic inconsistencySingle policy source; tests for every (role × menu) pair
getCurrentRoleUser matches roleName; checkMenuAuth reads accessRoleInconsistentReplace with single User.HasPermission(menu, action) method on the aggregate root
Hardcoded maxLeaveDay/minLeaveDay per typePolicy frozen in filterArr[0]LeavePolicy value object loaded from a leave_policies table; editable by HR without deploy
orgUnitCode.substring(0,4) !== '0734' magic stringBrittle org ruleOrgUnit.IsHRHQ boolean field; configurable
adminCanPass boolean argClient-controlled bypass riskServer-side User.CanBypassLeaveLimits() derived from role, never from client input
13-state WDHospital ENUMTypos (DRAF, PEDDING_CANCLE) baked inCanonical state machine in welfare domain; status is a typed enum; legacy strings mapped at ACL boundary
relationHrmi max 5 childrenHardcoded capRelationPolicy with configurable child ceiling
KBANK status mapping in config.jsVendor-coupledBankGateway port; KBANK adapter maps codes; new banks = new adapter
execSync(qpdf) for encrypted PDFsSEC-1, PERF-1document-renderer service (Go + chromedp); PDF password via streaming, no shell
In-process node-schedule cronsNo HAasynq + Redis (Go) with distributed locks
fetchPolicy: 'no-cache'Perf killerNuxt 3 useFetch + stale-while-revalidate; normalized cache

Phase 4 complete. Proceed to 05-tech-debt-register.md — the consolidated technical-debt and modernization register.