giw-admin — module tích hợp công ty thành viên

Trạng thái: POC · Ngày: 23/09/2026 · Chạy trong: Keycloak 26.7.4, dạng RealmResourceProvider

Một module Keycloak gộp bốn thứ mà bình thường là bốn service: cấu hình kết nối tới công ty thành viên, import người dùng theo lô, webhook nhận sự kiện, và trình biên tập bảng quyết định. Đây là lựa chọn có chủ đích cho MVP POC để trình bày trọn luồng tích hợp ở một chỗ. ADR-012 ghi kiến trúc đích và cái giá phải trả khi làm như hiện tại.


1. Vì sao nằm trong Keycloak

ADR-012 lập luận cấu hình tích hợp nên nằm ngoài Keycloak, và phân tích đó vẫn là hướng đi. MVP POC làm ngược lại có chủ đích: tách bốn mối quan tâm thành bốn service khi chưa ai chốt tích hợp trông thế nào chỉ mua thêm phức tạp vận hành mà không rõ ràng thêm.

Cái giá, nói thẳng thay vì để phát hiện sau:

Mối quan tâm Đích (ADR-012) Hiện tại Nợ để lại
Cấu hình kết nối Service riêng, config trong git Realm attribute giw.company.{code} Không phiên bản, không review, lọt vào bản export realm
Secret tích hợp Vault ngoài Realm attribute giw.secret.{code}, chỉ-ghi Không xoay vòng theo lịch được; nằm cạnh chính cấu hình nó bảo vệ
Import theo lô Service provisioning Trong module, preview rồi commit Không quay lui, không nhật ký bền
Webhook Bộ nhận riêng, có hàng đợi Trong module, HMAC + chống trùng trong RAM Sự kiện rơi đúng lúc Keycloak restart là mất
Nhật ký sự kiện Kho bền Ring buffer 200 bản ghi trong RAM Mất khi restart

Dòng webhook là dòng quan trọng với production: công ty thành viên retry vài lần rồi bỏ, và một nhân viên nghỉ việc không ai ghi nhận. Theo dõi ở T-WEBHOOK-DURABILITY.

2. Vì sao là RealmResourceProvider chứ không phải UI tab

Keycloak 26.7.4 có sẵn UiTabProvider và UiPageProvider, nhưng cả hai đều khai báo: khai báo thuộc tính cấu hình rồi Keycloak dựng form từ đó. Cách đó không làm được upload tệp kèm tiến độ import, lưới bảng quyết định sửa được, hay nhật ký sự kiện webhook. UiScriptProvider — thứ cho phép nhúng UI tuỳ ý vào admin console — không có trong bản này.

Nên module tự phục vụ trang của nó tại /realms/{realm}/giw-admin/. Vẫn chạy trong Keycloak, vẫn nằm cùng một JAR, vẫn dùng session và xác thực của Keycloak.

3. Xác thực

Endpoint của RealmResourceProvider mặc định KHÔNG xác thực. Keycloak đưa request cho bạn, hết. Quên điều này là công khai chức năng import người dùng cho bất kỳ ai chạm tới cổng, nên mọi lệnh gọi /api/* đều đi qua một chốt:

  • bearer token của realm, do chính BearerTokenAuthenticator của Keycloak kiểm
  • có quyền realm-management/manage-users

Không phải realm-admin: module này cấp phát người dùng, không quản trị realm. Token admin của realm master bị từ chối — module xác thực theo đúng realm nó đang phục vụ, còn token master do một realm khác phát hành.

Bản thân trang GUI thì công khai. Trình duyệt điều hướng không mang header Authorization, nên đặt trang sau bearer token nghĩa là trang không bao giờ tải được. Trang không chứa dữ liệu; mọi thứ nó lấy đều qua API đã xác thực.

Console đăng nhập bằng Authorization Code + PKCE

Client public giw-admin-console, code_challenge_method=S256, direct grant tắt.

Đây là ngược lại với captive portal — nơi dùng Direct Grant và bị đánh dấu POC-ONLY ở mọi tài liệu. Portal có ràng buộc thật: captive portal không redirect tin cậy được khi trình duyệt chưa có đường ra Internet. Console quản trị không có ràng buộc đó, nên dùng đúng luồng mà nền tảng muốn giữ. Xem AUTH-STRATEGY.

4. Ghép người theo CCCD mà không lưu CCCD

Yêu cầu là "tạo mới hoặc liên kết người dùng theo CCCD". Cách làm hiển nhiên là lưu số CCCD thành attribute rồi so khớp — và như vậy là dựng đúng cái thứ mà mọi phần còn lại của nền tảng từ chối dựng: một kho tra cứu được số căn cước của sáu công ty.

Thay vào đó module lưu HMAC-SHA256(cccd, salt của realm) ở attribute cccd_link. Salt sinh một lần cho mỗi realm, giữ tại giw.cccd.salt. Số gốc bị bỏ ngay sau khi băm: không lưu, không log, không trả về ở bất kỳ response nào.

Chừng đó đủ trả lời "có phải cùng một người đã import tuần trước không", và không đủ để khôi phục số hay thử đoán nếu chưa có salt.

Những gì đánh đổi, có chủ đích:

  • không tra cứu được người bằng cách gõ CCCD vào admin console
  • xoay salt là mất toàn bộ liên kết cũ

Cả hai chấp nhận được. Giữ số căn cước của sáu công ty thì không.

Đầu vào bị từ chối nếu không khớp ^[0-9]{9,12}$ — dòng có CCCD sai định dạng sẽ ghép theo khoá khác hoặc tạo mới, thay vì sinh ra một mã băm của lỗi gõ nhầm.

5. Import

Thử trước và ghi thật là hai thao tác khác nhau

preview tính xem sẽ xảy ra gì và không ghi gì. commit thì làm thật. Thử trước tồn tại vì một lượt import hỏng giữa chừng để lại realm ở trạng thái không ai định trước; người import danh sách nhân sự thật phải thấy "412 tạo mới, 38 ghép, 6 xung đột" trước khi chạm vào bất cứ thứ gì.

Thử trước so từng dòng với realm ở trạng thái hiện tại, không so với các dòng đứng trước trong cùng tệp. Hai dòng cùng một CCCD sẽ thử trước ra "tạo mới ×2" và ghi thật ra "tạo mới + ghép". Số liệu lệch giữa hai chế độ là bình thường, không phải lỗi.

Thứ tự ghép

  1. Token CCCD — cùng một người, kể cả email khác
  2. employee_ref — đã import trước đó
  3. email — khách hàng Galaxy ID đã có, hoá ra là nhân viên
  4. không khớp gì — tạo mới

Thứ tự không tuỳ tiện: CCCD định danh một con người, email định danh một hộp thư.

Trường hợp 3 trả về LINK_BY_EMAIL và được GUI tô cảnh báo. Gắn quan hệ lao động vào một tài khoản do chính người dùng tự đăng ký là việc cần xem, không phải việc cho qua.

Trùng CCCD là xung đột, không phải ghi đè

Nếu CCCD của một dòng khớp với người dùng đã mang employee_ref khác, dòng đó trả về CONFLICT và không ghi gì:

This CCCD is already linked to EMP-90002. Resolve by hand before importing EMP-90001.

Có thể là tái tuyển dụng, có thể hai người trùng vì gõ nhầm CCCD, cũng có thể là một dòng sắp chiếm tài khoản của người khác. Ghi đè im lặng là lựa chọn duy nhất giấu mất đó là trường hợp nào.

Import ghi gì, và không ghi gì

Ghi ở mọi nhánh: user_type=EMPLOYEE, employee_ref, company, giw_imported_at, và cccd_link khi có CCCD hợp lệ.

Không bao giờ ghi: citizenId, employeeId.

Không ghi employee_verified. Import là nạp dữ liệu, không phải xác minh. Chỉ một lượt hỏi HR trực tiếp lúc đăng nhập mới được đặt cờ này. Đây là dòng quan trọng nhất của module — một lượt import mà đánh dấu người ta đã xác minh là biến một bảng tính thành một quyết định xác thực.

Về tên tài khoản

Tài khoản mới tạo dưới dạng emp-{số}. Nhưng khi realm bật registrationEmailAsUsername — như galaxy-id-poc — Keycloak đổi username thành email. Kết quả import báo về tên đăng nhập đúng như admin console hiển thị, để người vận hành không đi tìm một tài khoản đang nằm dưới tên khác.

6. Webhook

POST /realms/{realm}/giw-admin/webhook/{company}

Xác thực bằng HMAC trên raw body, không bằng bearer token: bên gọi là hệ HR của công ty thành viên, không phải người vận hành.

X-GIW-Signature: base64(HMAC-SHA256(secret, "{timestamp}.{rawBody}"))
X-GIW-Timestamp: unix seconds, từ chối nếu lệch quá ±5 phút
X-GIW-Event-Id:  chống trùng

Ký trên đúng byte nhận được mới là điểm mấu chốt. Ký trên object đã parse rồi serialize lại là kiểm tra bộ serialize của chính mình, không phải kiểm tra bên gửi.

Chữ ký so bằng MessageDigest.isEqual — thời gian hằng số. So từng byte rồi thoát sớm sẽ lộ ra chữ ký giả đã đúng được bao nhiêu.

Sự kiện chặn cửa và sự kiện làm giàu

Chỉ sự kiện ảnh hưởng quyền đăng nhập mới được áp dụng:

Sự kiện Tác động
EMPLOYEE_TERMINATED Vô hiệu hoá tài khoản, employee_verified=false
EMPLOYEE_SUSPENDED Vô hiệu hoá tài khoản
EMPLOYEE_REINSTATED Bật lại tài khoản. Không khôi phục trạng thái đã xác minh
EMPLOYEE_COMPANY_CHANGED Cập nhật attribute company

Mọi loại khác — đổi hạng hội viên, mua gói — chỉ ghi nhận, không áp dụng. Ghi hạng hội viên vào Keycloak là biến nó thành bản sao của hệ loyalty, và là bản sao cũ. Entitlement hỏi thẳng nguồn đúng lúc nó cần câu trả lời.

Phục hồi cố ý không trả lại employee_verified. Tài khoản đăng nhập lại được; còn người đó có còn là nhân viên hay không là câu hỏi dành cho HR ở lần đăng nhập kế tiếp, không dành cho một webhook.

Chống trùng

Nhà cung cấp nào cũng retry. Áp dụng EMPLOYEE_TERMINATED hai lần thì vô hại; áp dụng lệch thứ tự sau một lần retry là cách một người vừa được phục hồi bị vô hiệu hoá lại. Event id được nhớ trong deque 2000 phần tử, gửi lại trả DUPLICATE mà không áp dụng lần nữa.

Nằm trong RAM. Restart là sạch, và cửa sổ nó bảo vệ mất theo.

7. Biên tập bảng quyết định

Tab DMN đọc và ghi file của dmn-service thông qua module:

GET  /api/dmn/models                xem đang nạp gì
GET  /api/dmn/models/{name}         đọc nguồn .dmn
PUT  /api/dmn/models/{name}         thay thế, biên dịch, nạp lại
POST /api/dmn/evaluate/{name}       thử một quyết định, không lưu

GUI parse DMN thành lưới sửa được, nên đổi một luật không cần đọc XML. XML gốc cũng sửa được, cho những gì lưới không diễn đạt nổi.

Một lần sửa hỏng không được phép làm sập bộ luật. Khi PUT, byte cũ được giữ lại; nếu tài liệu mới không biên dịch được thì byte cũ được ghi lại và runtime trước đó được khôi phục trước khi báo lỗi cho bên gọi. Cách ngược lại — ghi trước, nạp sau — để lại một service từ chối tất cả mọi người chỉ vì ai đó gõ sai một biểu thức FEEL.

POST .../evaluate trả 422 khi model không đánh giá được, không bao giờ trả về dạng từ chối. Một bộ luật không chạy được và một lần từ chối hợp lệ không được phép giống nhau dưới mắt bên gọi.

⚠️ Chưa có quy trình duyệt. Ai cầm manage-users là đổi được ai nhận quyền gì, không lịch sử phiên bản, không người duyệt. Đúng khoảng trống đã ghi cho POST /api/v1/decisions/reload — xem DMN-DECISION-SPEC §7.

8. Cấu hình kết nối

Lưu bằng realm attribute, mỗi công ty một tài liệu JSON tại giw.company.{code}:

{
  "name": "Vietjet Air",
  "enabled": true,
  "hrEndpoint": "http://mock-hr-api:3001/api/v1/employees/verify",
  "loyaltyEndpoint": "http://mock-loyalty-api:3006/api/v1/members/lookup",
  "timeoutMs": 3000,
  "employeeIdPrefix": "VJ",
  "updatedAt": "2026-09-23T03:07:28.325Z",
  "updatedBy": "giw-operator@giw.test"
}

Secret ký webhook nằm riêng ở giw.secret.{code} và chỉ ghi được qua API: đặt được, và không response nào trả nó về. Endpoint liệt kê chỉ báo hasSecret: true|false, không hơn.

⚠️ Chưa nối vào luồng đăng nhập. Authenticator đọc hrEndpoint từ authenticatorConfig của flow, không đọc từ đây. Sửa endpoint ở màn này không đổi được địa chỉ Keycloak thật sự gọi lúc đăng nhập, và test đang thử địa chỉ ở màn này chứ không phải địa chỉ đang dùng. Nối hai chỗ lại cần chốt T-HR-ROUTING — biết hỏi hệ HR nào trước khi biết câu trả lời. Ghi ở DEFERRED-TASKS D5.

POST /api/companies/{code}/test thăm dò GET {origin-của-endpoint}/health với timeout của chính công ty đó, trả UP / HTTP_{n} / UNREACHABLE / NOT_CONFIGURED. Khi lỗi chỉ báo tên lớp exception, không bao giờ báo message — message có thể mang theo URL, header, hoặc một mảnh payload.

Bootstrap gieo sẵn một dòng cho mỗi công ty trong EMPLOYEE_COMPANIES, trỏ vào mock. Không gieo secret: một secret nằm trong repository thì không còn là secret.

9. Những gì chưa giải quyết ở đây

Ref Câu hỏi
T-USER-SYNC Sao chép hay liên kết? Module này sao chép. UserStorageProvider sẽ liên kết và để nghĩa vụ pháp lý lại ở hệ nguồn. Đây là quyết định về dữ liệu, không phải về kiến trúc
T-WEBHOOK-DURABILITY Sự kiện mất khi restart. Chấp nhận tới bao giờ, bù bằng gì — đối soát theo lịch, hay hàng đợi?
T-ADMIN-SPLIT MVP nào tách module này thành service, kích hoạt bởi điều gì — công ty thứ hai, hay sự kiện đầu tiên bị mất?
T-EVENT-CLASS Bốn loại sự kiện đang áp dụng chưa đối chiếu với danh mục của một hệ HR thật
T-HR-ROUTING Biết hỏi hệ HR nào trước khi biết câu trả lời. Tuyệt đối không hỏi tất cả — làm vậy là nhân số lần phơi nhiễm CCCD lên bằng số công ty

10. Kiểm chứng

22 test end-to-end, nhóm 8 của tests/e2e.mjs. Nhóm này nhắm vào những chỗ hỏng âm thầm: token khách hàng bị từ chối 403, thử trước không ghi gì, CCCD không rơi vào user record, webhook sai chữ ký / quá hạn / trùng event id đều bị chặn, và một tài liệu DMN hỏng bị từ chối trong khi bảng cũ vẫn đánh giá được. Nhóm tự xoá dữ liệu nó tạo ra.

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