Hướng dẫn thực hành — module giw-admin và Camunda Modeler

Trạng thái: POC · Ngày: 24/09/2026 Mọi lệnh trong tài liệu này đã chạy thật trên stack và kết quả in ra là kết quả thật.

Tài khoản demo — một cái duy nhất

giw-operator@giw.test
Operator!23

Đăng nhập một lần tại Admin Console của realm sản phẩm:

http://localhost:8080/admin/galaxy-id-poc/console/

Rồi bấm Member Company Integration ở cuối nhóm Configure — module mở ra và không hỏi mật khẩu lần hai. Cùng realm nên phiên SSO đi thẳng qua.

Link mở ở tab mới, và sessionStorage không theo sang tab mới — nên module hỏi Keycloak bằng prompt=none ngay lúc tải để nhặt lại phiên đang có. Vào thẳng, không phải bấm gì. Chỉ khi thật sự chưa có phiên thì nút Đăng nhập mới hiện.

Chú ý đường dẫn: /admin/**galaxy-id-poc**/console/, không phải /admin/master/console/. Đăng nhập vào console của master thì module vẫn bắt đăng nhập lại, vì nó chỉ nhận token của realm nó đang phục vụ.

Nếu trình duyệt còn phiên cũ và bạn muốn thấy lại form đăng nhập, mở link đăng xuất trước rồi bấm Logout:

http://localhost:8080/realms/galaxy-id-poc/protocol/openid-connect/logout

Đừng tự dựng URL …/protocol/openid-connect/auth?…&code_challenge=… cho module — module tự sinh code_verifier/state trong sessionStorage, URL dựng tay sẽ hỏng ở bước callback.

Tài khoản cho portal, khi cần thử luồng hành khách:

Vai Tài khoản Mật khẩu
Khách hàng (GOLD) an.nguyen@example.test Passw0rd!23
Cán bộ nhân viên (VIETJET) VJ12345 CCCD 001234567890

Danh sách đầy đủ, kèm khoá dịch vụ: DEMO.md §3.0.


Phần A — Tích hợp thử hai API mockup

Hai API giả lập đang chạy sẵn:

Service Cổng Đóng vai Dữ liệu
mock-hr-api 3001 Hệ thống nhân sự gốc 100 nhân viên · 6 công ty · 5 trạng thái lao động
mock-loyalty-api 3006 GalaxyJoy / SkyJoy 12 hội viên · hạng GOLD/SILVER/NONE

A1. Mở module

Cách 1 — từ menu Admin Console. Keycloak không có SPI nào thêm được một link vào menu: UiTabProvider và UiPageProvider chỉ render form khai báo. Nên admin theme nạp thêm một script nhỏ (admin/resources/js/giw-nav.js) chèn mục này vào nav trái:

Admin Console → cột trái → nhóm Configure → mục cuối
    Member Company Integration          (mở ở tab mới)

Link tự trỏ theo realm đang quản lý: đang ở galaxy-id-poc thì mở module của realm đó; đổi sang master thì trỏ sang master, và module ở đó sẽ trống — cấu hình công ty nằm ở realm sản phẩm.

Script viết theo kiểu hỏng-thì-im: nếu bản Keycloak sau đổi cấu trúc nav, hậu quả tệ nhất là thiếu một mục menu, không bao giờ là vỡ console.

Cách 2 — đi thẳng:

open http://localhost:8080/realms/galaxy-id-poc/giw-admin/

A1b. Đăng nhập

Nếu đã đăng nhập ở Admin Console của realm galaxy-id-poc thì bấm Đăng nhập là vào thẳng, không hỏi gì thêm.

Nếu vào module bằng đường dẫn trực tiếp: giw-operator@giw.test / Operator!23.

⚠️ admin/admin không vào được module. Đó là tài khoản realm master; module chỉ nhận token của đúng realm nó đang phục vụ.

Đây là Authorization Code + PKCE, không phải Direct Grant. Tài khoản nằm trong realm galaxy-id-poc chứ không phải master, và chỉ có quyền manage-users + view-users.

A2. Tab Công ty thành viên — đấu hai API vào

Bootstrap đã gieo sẵn ba công ty (VIETJET, GALAXY, SOVICO), mỗi công ty trỏ vào hai mock:

HR endpoint       http://mock-hr-api:3001/api/v1/employees/verify
Loyalty endpoint  http://mock-loyalty-api:3006/api/v1/members/lookup

Dùng tên service trong mạng Docker, không phải localhost — Keycloak gọi từ bên trong mạng container.

Bấm Thử kết nối trên VIETJET. Kết quả mong đợi:

hr: UP 81ms      loyalty: UP 2ms

Kiểm bằng dòng lệnh:

T=$(curl -s -d 'client_id=admin-cli' -d 'username=giw-operator@giw.test' \
  -d 'password=Operator!23' -d 'grant_type=password' \
  http://localhost:8080/realms/galaxy-id-poc/protocol/openid-connect/token \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
G=http://localhost:8080/realms/galaxy-id-poc/giw-admin

curl -s -X POST -H "Authorization: Bearer $T" $G/api/companies/VIETJET/test | python3 -m json.tool

Thử một công ty cấu hình sai để thấy nó phát hiện được: sửa HR endpoint của GALAXY thành http://mock-hr-api:9999/... rồi bấm lại — chip đổi thành UNREACHABLE.

⚠️ Phải nói rõ khi demo. Ô endpoint ở màn này chưa nối vào luồng đăng nhập. Authenticator vẫn đọc hrEndpoint từ authenticatorConfig của flow. Nghĩa là: Thử kết nối đang thử địa chỉ bạn gõ ở đây, còn lúc CBNV đăng nhập thật thì Keycloak gọi địa chỉ trong flow. Nối hai chỗ lại cần chốt T-HR-ROUTING — biết phải hỏi hệ HR nào trước khi biết người đó thuộc công ty nào. Ghi ở DEFERRED-TASKS D5.

Cái gì ở màn này có tác dụng thật ngay bây giờ:

Trường Có tác dụng Dùng ở đâu
Mã công ty ✅ Khoá của import và của webhook
Webhook secret ✅ Ký HMAC cho webhook
Bật/tắt, tên hiển thị ✅ Hiển thị và lọc
HR / Loyalty endpoint ⚠️ chỉ dùng cho Thử kết nối Chưa vào luồng đăng nhập
Timeout ⚠️ chỉ dùng cho Thử kết nối Luồng đăng nhập dùng GIW_HR_TIMEOUT_MS

A3. Đường HR thật sự chạy: đăng nhập CBNV

Đây mới là chỗ mock-hr-api được gọi thật. Keycloak gọi, không phải portal.

J=$(mktemp)
V=$(curl -s -c $J -b $J 'localhost:3000/api/v1/consent/document?locale=vi' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["document"]["version"])')
curl -s -c $J -b $J -X POST localhost:3000/api/v1/consent/grants \
  -H 'Content-Type: application/json' \
  -d "{\"documentVersion\":\"$V\",\"accepted\":[\"NETWORK_ACCESS\",\"IDENTITY_VERIFICATION\"],\"locale\":\"vi\"}" -o /dev/null

curl -s -c $J -b $J -X POST localhost:3000/api/v1/auth/employee \
  -H 'Content-Type: application/json' \
  -d '{"employeeId":"VJ12345","citizenId":"001234567890"}' | python3 -m json.tool

Theo dấu lệnh gọi HR trong log:

docker logs giw-mock-hr-api --tail 5

Bốn nhân viên đáng thử, mỗi người ra một kết quả khác nhau:

employeeId citizenId Kết quả
VJ12345 001234567890 Vào được · WIFI_EMPLOYEE_PACKAGE / BOOST
VJ99999 001234500003 EMPLOYEE_INACTIVE — HR nói không còn hoạt động
PTX10001 001234500005 ENTITLEMENT_DENIED · COMPANY_NOT_ELIGIBLE — xác thực xong nhưng công ty chưa mở
VJ12345 009999999999 INVALID_CREDENTIALS — sai CCCD, thông báo giống hệt trường hợp không có mã

A4. Đường Loyalty thật sự chạy: đăng nhập khách hàng

Loyalty do entitlement-service gọi, và chỉ gọi khi khách đồng ý mục LOYALTY_BENEFIT.

run() {  # $1 = mô tả, $2 = danh sách đồng ý
  J=$(mktemp)
  V=$(curl -s -c $J -b $J 'localhost:3000/api/v1/consent/document?locale=vi' \
      | python3 -c 'import sys,json;print(json.load(sys.stdin)["document"]["version"])')
  curl -s -c $J -b $J -X POST localhost:3000/api/v1/consent/grants \
    -H 'Content-Type: application/json' \
    -d "{\"documentVersion\":\"$V\",\"accepted\":$2,\"locale\":\"vi\"}" -o /dev/null
  curl -s -c $J -b $J -X POST localhost:3000/api/v1/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"an.nguyen@example.test","password":"Passw0rd!23"}' \
  | python3 -c "
import sys,json;d=json.load(sys.stdin);e=d['entitlement']
print('  $1'.ljust(22), e['entitlementType'],'|',e['permission']['tier'])"
  rm -f $J
}
run "đồng ý loyalty"  '["NETWORK_ACCESS","IDENTITY_VERIFICATION","LOYALTY_BENEFIT"]'
run "từ chối loyalty" '["NETWORK_ACCESS","IDENTITY_VERIFICATION"]'

Kết quả:

  đồng ý loyalty         WIFI_CUSTOMER_PLUS | BOOST
  từ chối loyalty        WIFI_CUSTOMER_BASIC | BASIC

Cùng một người, khác nhau ở chỗ có đồng ý hay không. Khi từ chối, Loyalty không được gọi lần nào — không phải gọi rồi bỏ kết quả.

Thử Loyalty sập để thấy nó chỉ làm giàu chứ không chặn:

docker compose stop mock-loyalty-api
# chạy lại run "đồng ý loyalty" → vẫn vào được, nhưng tụt về BASIC
docker compose start mock-loyalty-api

A5. Tab Import người dùng — nạp danh sách từ công ty thành viên

Muốn chạy nhanh thì dùng tệp có sẵn: ô Tệp CSV → poc/identity/demo-data/import/vietjet.csv (9 nhân viên VIETJET, khớp đúng mock HR nên import xong đăng nhập được luôn). Bốn tệp trong demo-data/import/kich-ban/ ép ra từng ca khó. Chi tiết ở demo-data/import/README.md.

Muốn gõ tay để thấy rõ từng dòng, dán vào ô dữ liệu:

employeeId,employeeRef,citizenId,email,fullName
80001,,001199080001,tich.hop1@example.test,Nguyen Thi Lan
80002,,001199080001,tich.hop2@example.test,Nguyen Thi Lan
80003,,,tich.hop3@example.test,Tran Van Nam

Bấm Thử trước — không ghi gì. Rồi Ghi thật.

Dòng 2 dùng cùng CCCD với dòng 1: thử trước báo "tạo mới", ghi thật báo "ghép theo CCCD". Lệch nhau là bình thường — thử trước so với realm ở trạng thái hiện tại, chưa thấy dòng 1.

Cột employeeRef bỏ trống thì module tự suy ra EMP-{phần số}. Công ty nào có định dạng mã khác thì phải điền cột này, nếu không người vừa import sẽ không khớp lúc đăng nhập.

Dọn sau khi thử:

KT=$(curl -s -d 'client_id=admin-cli' -d 'username=admin' -d 'password=admin' \
  -d 'grant_type=password' \
  http://localhost:8080/realms/master/protocol/openid-connect/token \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
for u in tich.hop1 tich.hop2 tich.hop3; do
  ID=$(curl -s -H "Authorization: Bearer $KT" \
    "http://localhost:8080/admin/realms/galaxy-id-poc/users?search=$u" \
    | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d[0]["id"] if d else "")')
  [ -n "$ID" ] && curl -s -X DELETE -H "Authorization: Bearer $KT" \
    "http://localhost:8080/admin/realms/galaxy-id-poc/users/$ID"
done

A6. Tab Webhook & sự kiện — công ty thành viên đẩy thay đổi sang

Đặt secret cho VIETJET ở tab đầu, rồi:

G=http://localhost:8080/realms/galaxy-id-poc/giw-admin
SEC=poc-demo-secret
send() { TS=$(date +%s)
  SIG=$(printf '%s.%s' "$TS" "$1" | openssl dgst -sha256 -hmac "$SEC" -binary | base64)
  curl -s -X POST -H 'Content-Type: application/json' -H "X-GIW-Timestamp: $TS" \
    -H "X-GIW-Signature: $SIG" -H "X-GIW-Event-Id: $(uuidgen)" -d "$1" $G/webhook/VIETJET; echo; }

send '{"type":"EMPLOYEE_TERMINATED","employeeRef":"EMP-12345"}'
send '{"type":"MEMBER_TIER_CHANGED","employeeRef":"EMP-12345","tier":"PLATINUM"}'
send '{"type":"EMPLOYEE_REINSTATED","employeeRef":"EMP-12345"}'

Kết quả lần lượt APPLIED, RECORDED, APPLIED.

Sự kiện đổi hạng hội viên chỉ ghi nhận: viết hạng vào Keycloak là biến nó thành bản sao cũ của hệ loyalty. Entitlement hỏi Loyalty đúng lúc nó cần.


Phần B — Camunda Modeler để viết luật .dmn

Đã cài sẵn:

/Applications/Camunda Modeler.app   (5.51.1)

Nếu cần cài lại: brew install --cask camunda-modeler

B1. Luật nằm ở đâu

poc/identity/dmn-service/dmn/
├── wifi-entitlement.dmn    WifiEntitlement   — 7 luật, quyết định gói Wi-Fi
└── hr-eligibility.dmn      HrEligibility     — 5 luật, điều kiện từ dữ liệu HR

Thư mục này được mount vào container (./dmn-service/dmn:/app/dmn), nên sửa file trên máy là service thấy ngay — không build lại image.

B2. Mở và sửa

open -a "Camunda Modeler" "poc/identity/dmn-service/dmn/hr-eligibility.dmn"

Trong Modeler: bấm đúp vào ô quyết định trên sơ đồ DRD để mở bảng luật. Sửa ô, thêm dòng, rồi ⌘S.

Hai điều đã kiểm chứng để bạn khỏi mất thời gian nghi ngờ:

  • Namespace và cấu trúc khớp nhau. Template mà Modeler sinh ra khi New File dùng đúng namespace DMN 1.3 (https://www.omg.org/spec/DMN/20191111/MODEL/) và đúng cấu trúc dmndi:DMNDI mà Drools đang đọc.
  • Thuộc tính Camunda 8 không gây lỗi. File hr-eligibility.dmn cố ý mang modeler:executionPlatform="Camunda Cloud" và exporter="Camunda Modeler" — Drools bỏ qua chúng và nạp bình thường.

Lưu ý về DMNDI. File .dmn thiếu khối dmndi:DMNDI vẫn chạy được trên Drools nhưng mở trong Modeler ra khung trắng — người phụ trách nghiệp vụ sẽ tưởng mất hết luật. wifi-entitlement.dmn ban đầu thiếu khối này; đã bổ sung. File nào tự viết tay thì nhớ kèm theo.

B3. Nạp luật mới — ba cách

Cách 1 — qua GUI module (không cần terminal):

Tab Bảng quyết định → chọn model → sửa trên lưới → Lưu & nạp lại.

Module ghi file xuống đĩa, biên dịch, rồi nạp lại. Nếu XML hỏng, nó trả 422 và giữ nguyên bảng cũ đang chạy — một biểu thức FEEL gõ sai không làm cả hệ thống từ chối mọi người.

Cách 2 — sửa bằng Modeler rồi gọi reload:

curl -X POST localhost:3005/api/v1/decisions/reload
curl -s localhost:3005/api/v1/decisions | python3 -m json.tool   # xác nhận nạp file nào

Cách 3 — đẩy file qua API của module:

T=$(cat /tmp/op.tok)   # token operator lấy ở bước A2
G=http://localhost:8080/realms/galaxy-id-poc/giw-admin
python3 -c "
import json;print(json.dumps({'xml':open('poc/identity/dmn-service/dmn/hr-eligibility.dmn').read()}))" \
  > /tmp/dmn.json
curl -s -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
  --data-binary @/tmp/dmn.json $G/api/dmn/models/HrEligibility | python3 -m json.tool

B4. Thử luật trước khi tin

curl -s -X POST localhost:3005/api/v1/decisions/HrEligibility/evaluate \
  -H 'Content-Type: application/json' \
  -d '{"inputs":{"employeeStatus":"ACTIVE","wifiEligible":true,"company":"VIETJET"}}' \
  | python3 -m json.tool

Chạy hết 5 luật với dữ liệu HR thật:

employeeId employeeStatus wifiEligible company → eligible reason
VJ12345 ACTIVE true VIETJET true OK
VJ99999 INACTIVE false VIETJET false EMPLOYEE_NOT_ACTIVE
VJ55555 ACTIVE false VIETJET false NOT_WIFI_ELIGIBLE
PTX10001 ACTIVE true PARTNER-X false COMPANY_NOT_ELIGIBLE
VJ10003 ON_LEAVE true VIETJET false EMPLOYEE_NOT_ACTIVE
GLX10103 TERMINATED false GALAXY false EMPLOYEE_TERMINATED
SVC10202 PROBATION true SOVICO false EMPLOYEE_NOT_ACTIVE

Tên trường đầu vào lấy nguyên xi từ mock-hr-api, nên khi nối HR thật chỉ cần ánh xạ tên, không phải viết lại bảng.

B5. Viết một model mới từ đầu

  1. Trong Modeler: File → New File → DMN Diagram
  2. Lưu vào poc/identity/dmn-service/dmn/<tên>.dmn
  3. Đổi name của <definitions> — đây là tên model mà API gọi tới, không phải tên file
  4. Kéo ô Decision, bấm đúp để mở bảng, thêm input/output
  5. ⌘S rồi curl -X POST localhost:3005/api/v1/decisions/reload

Vài điều dễ vấp:

Vấp Nguyên nhân
MODEL_NOT_FOUND Gọi bằng tên file thay vì name trong <definitions>
DMN_EVALUATION_ERROR kèm "Required dependency X not found" Thiếu một input mà bảng cần. Phải truyền đủ mọi input model khai báo
Sửa xong không thấy đổi Chưa gọi /reload, hoặc lưu nhầm ra ngoài thư mục dmn/
Chuỗi so sánh không khớp FEEL cần nháy kép: "VIETJET", không phải VIETJET
Ô để trống Nghĩa là "bất kỳ giá trị nào", không phải "rỗng". Muốn khớp rỗng thì viết null

Hit policy. Cả hai bảng hiện dùng FIRST — luật đầu tiên khớp sẽ thắng, nên thứ tự dòng có ý nghĩa. Đổi sang UNIQUE thì hai luật cùng khớp sẽ thành lỗi thay vì âm thầm lấy dòng trên.

B6. Khoảng trống phải biết

⚠️ Không có quy trình duyệt. Ai cầm manage-users là sửa được luật cấp quyền cho toàn hệ thống, không phiên bản, không người duyệt, không vết ai đổi gì. POST /api/v1/decisions/reload thì thậm chí không xác thực. Ghi ở API-SECURITY.md §11 và DMN-DECISION-SPEC §7. Phải xử lý trước production.


Kiểm lại toàn bộ

cd poc/identity && node tests/e2e.mjs

Mong đợi 143 test · 143 pass · 0 fail.

GIW POC Identity Platform · bản demo local · không phải production · sinh từ repository