giw-admin β member company integration module
Status: POC Β· Date: 2026-09-23 Β· Runs in: Keycloak 26.7.4, as a RealmResourceProvider
One Keycloak module holding four things that would normally be four services: member company connector config, batch user import, an inbound webhook, and a decision-table editor. This is a deliberate MVP POC choice so the whole integration story can be demonstrated in one place. ADR-012 records the target architecture and what this costs today.
1. Why it is inside Keycloak
ADR-012 argues for keeping integration config outside Keycloak, and that analysis still stands as the direction of travel. The MVP POC does the opposite on purpose: splitting four concerns across four services before anyone has agreed what the integration even looks like buys operational complexity and no clarity.
What that costs, stated plainly rather than discovered later:
| Concern | Target (ADR-012) | Here | Cost carried |
|---|---|---|---|
| Connector config | Separate service, config in git | Realm attribute giw.company.{code} |
No version history, no review, lands in a realm export |
| Integration secret | External vault | Realm attribute giw.secret.{code}, write-only |
Cannot be rotated on a schedule; sits beside the config it protects |
| Batch import | Provisioning service | In the module, preview then commit |
No rollback, no durable audit trail |
| Webhook | Separate receiver with a queue | In the module, HMAC + in-memory dedupe | An event arriving while Keycloak restarts is lost |
| Event log | Durable store | 200-entry in-memory ring | Lost on restart |
The last row in the webhook line is the one that matters for production: a member company retries a few times and gives up, and an employee's departure goes unrecorded. Tracked as T-WEBHOOK-DURABILITY.
2. Why a RealmResourceProvider and not a UI tab
Keycloak 26.7.4 ships UiTabProvider and UiPageProvider, but both are declarative: you declare config properties and Keycloak renders a form from them. That cannot do a file upload with import progress, an editable decision grid, or a webhook event log. UiScriptProvider, which would allow arbitrary UI inside the admin console, is not in this version.
So the module serves its own page at /realms/{realm}/giw-admin/. It still runs inside Keycloak, ships in the same JAR, and uses Keycloak's own session and authentication.
3. Authentication
A RealmResourceProvider endpoint is unauthenticated by default. Keycloak hands you the request and nothing more. Forgetting that would publish batch user import to whoever can reach the port, so every /api/* call goes through one check:
- a realm bearer token, verified by Keycloak's own
BearerTokenAuthenticator - carrying
realm-management/manage-users
Not realm-admin: this module provisions users, it does not administer the realm. A master-realm admin token is rejected β the module authenticates against the realm it is serving, and a master token is issued by a different realm.
The GUI shell itself is public. A browser navigation carries no Authorization header, so serving the page behind a bearer token would mean it could never load. The page contains no data; it fetches everything through the authenticated API.
The console logs in with Authorization Code + PKCE
Public client giw-admin-console, code_challenge_method=S256, direct grants disabled.
This is the opposite of the captive portal, which uses Direct Grant and is marked POC-ONLY throughout. The portal has a real constraint: a captive portal cannot reliably redirect a browser that has no route to the internet yet. An admin console has no such constraint, so it uses the flow the platform intends to keep. See AUTH-STRATEGY.
4. Linking people by CCCD without storing a CCCD
The requirement is "create or link a user by CCCD". The obvious implementation stores the number as a user attribute and matches on it β which builds exactly the thing the rest of this platform refuses to build: a searchable database of citizen IDs for six companies.
Instead the module stores HMAC-SHA256(cccd, realm salt) in the attribute cccd_link. The salt is generated once per realm and kept at giw.cccd.salt. The raw number is dropped immediately after hashing; it is not stored, not logged, and not echoed in any response.
That is enough to answer "is this the same person we imported last week", and not enough to recover the number or to test a guess without already holding the salt.
What this gives up, deliberately:
- you cannot look a person up by typing their CCCD into the admin console
- rotating the salt orphans every existing link
Both are acceptable. Holding six companies' citizen IDs is not.
Input is rejected unless it matches ^[0-9]{9,12}$ β a row with a malformed CCCD links by another key or creates, rather than producing a hash of a typo.
5. Import
Preview and commit are different operations
preview works out what would happen and writes nothing. commit does it. Preview exists because an import that goes wrong halfway leaves a realm in a state nobody planned; anyone importing a real company's staff list should see "412 create, 38 link, 6 conflict" before touching anything.
Preview evaluates each row against the realm as it is now, not against earlier rows in the same file. Two rows sharing a CCCD preview as two creates and commit as create + link. The counts differing between the modes is expected, not a defect.
Match order
- CCCD link token β the same person, even under a different email
employee_refβ already imported before- email β an existing Galaxy ID customer who turns out to be staff
- nothing matched β create
The order is not arbitrary: a CCCD identifies a person, an email identifies an inbox.
Match 3 is reported as LINK_BY_EMAIL and flagged for review in the GUI. Attaching employment to an account someone registered themselves is the kind of thing that should be looked at, not waved through.
A CCCD collision is a conflict, not an overwrite
If a row's CCCD matches a user who already carries a different employee_ref, the row is returned as CONFLICT and nothing is written:
This CCCD is already linked to EMP-90002. Resolve by hand before importing EMP-90001.
It could be a re-hire, two people sharing a mistyped CCCD, or a row about to take over someone else's account. Overwriting silently is the one outcome that hides which.
What import writes, and what it does not
Written on every path: user_type=EMPLOYEE, employee_ref, company, giw_imported_at, and cccd_link when a usable CCCD was supplied.
Never written: citizenId, employeeId.
Not written: employee_verified. An import is a data load, not a verification. Only a live HR check at login may set it. This is the single most important line in the module β an import that marked people verified would turn a spreadsheet into an authentication decision.
A note on usernames
New accounts are created as emp-{number}. Where the realm sets registrationEmailAsUsername β as galaxy-id-poc does β Keycloak rewrites the username to the email. The import result reports the login name as the admin console shows it, so an operator is not sent looking for an account that is listed under a different name.
6. Webhook
POST /realms/{realm}/giw-admin/webhook/{company}
Authenticated by HMAC over the raw body, not by a bearer token: the caller is a member company's HR system, not an operator.
X-GIW-Signature: base64(HMAC-SHA256(secret, "{timestamp}.{rawBody}"))
X-GIW-Timestamp: unix seconds, rejected outside Β±5 minutes
X-GIW-Event-Id: duplicate suppression
Signing the bytes as received is the point. Signing a re-serialised object verifies our own serialiser, not the sender.
The signature is compared with MessageDigest.isEqual, which is constant-time. A byte-by-byte comparison leaks how much of a forged signature was right.
Gate events and enrichment events
Only events that change who can log in are applied:
| Event | Effect |
|---|---|
EMPLOYEE_TERMINATED |
Account disabled, employee_verified=false |
EMPLOYEE_SUSPENDED |
Account disabled |
EMPLOYEE_REINSTATED |
Account enabled. Verification not restored |
EMPLOYEE_COMPANY_CHANGED |
company attribute updated |
Everything else β a loyalty tier changing, a package bought β is recorded and not applied. Writing a tier into Keycloak would make it a cache of the loyalty system, and a stale one. Entitlement asks the source at the moment it needs the answer.
Reinstatement deliberately does not restore employee_verified. The account can log in again; whether the person is still an employee is a question for HR at the next login, not for a webhook.
Duplicate suppression
Providers retry. Applying EMPLOYEE_TERMINATED twice is harmless; applying an event out of order after a retry is how a reinstated employee ends up disabled again. Event ids are remembered in a 2000-entry deque and a repeat returns DUPLICATE without re-applying.
In memory. A restart empties it, and the window it protects is gone.
7. Decision table editing
The DMN tab reads and writes dmn-service's files through the module:
GET /api/dmn/models read what is loaded
GET /api/dmn/models/{name} read the .dmn source
PUT /api/dmn/models/{name} replace it, compile, reload
POST /api/dmn/evaluate/{name} try a decision without saving
The GUI parses the DMN into an editable grid, so a rule change does not require reading XML. The raw XML is also editable, for anything the grid cannot express.
A bad edit must not take the rule set down. On PUT the old bytes are kept; if the new document does not compile they are written back and the previous runtime restored before the caller is told what went wrong. The alternative β write first, reload later β leaves a service that denies everyone because someone mistyped a FEEL expression.
POST .../evaluate returns 422 when the model cannot be evaluated, never a denial. A rule set that cannot run and a legitimate refusal must not look the same to a caller.
β οΈ No approval workflow. Anyone holding manage-users can change who gets access to what, with no version history and no reviewer. Same gap already recorded for POST /api/v1/decisions/reload β see DMN-DECISION-SPEC Β§7.
8. Connector config
Stored as realm attributes, one JSON document per company at 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"
}
The webhook secret lives separately at giw.secret.{code} and is write-only through the API: it can be set, and no response anywhere returns it. The list endpoint reports hasSecret: true|false and nothing more.
β οΈ Not wired into the login path yet. The employee authenticator reads
hrEndpointfrom the flow'sauthenticatorConfig, not from here. Editing an endpoint on this screen does not change the address Keycloak actually calls at login, andtestprobes the address on this screen rather than the one in use. Connecting the two requires settling T-HR-ROUTING β which HR system to ask, before the answer is known. Tracked asDEFERRED-TASKSD5.
POST /api/companies/{code}/test probes GET {endpoint-origin}/health with that company's timeout and reports UP / HTTP_{n} / UNREACHABLE / NOT_CONFIGURED. Failures report an exception class name, never a message β an exception message can carry a URL, a header, or a fragment of a payload.
Bootstrap seeds one row per company in EMPLOYEE_COMPANIES, pointing at the mocks. No secret is seeded: a shared secret that ships in a repository is not a secret.
9. What is not solved here
| Ref | Question |
|---|---|
| T-USER-SYNC | Copy or federate? This module copies. UserStorageProvider would federate and keep the legal obligation with the source system. A data decision, not an architecture one |
| T-WEBHOOK-DURABILITY | Events lost during a restart. Accept for how long, and compensate with what β scheduled reconciliation, a queue? |
| T-ADMIN-SPLIT | Which MVP splits this module into services, triggered by what β the second company, the first lost event? |
| T-EVENT-CLASS | The four applied event types have not been checked against a real HR system's catalogue |
| T-HR-ROUTING | Which HR system to ask, before the answer is known. Never by asking all of them β that multiplies CCCD exposure by the number of companies |
10. Verification
22 end-to-end tests, group 8 of tests/e2e.mjs. They cover the parts that fail quietly: a customer token is refused with 403, a preview writes nothing, no CCCD reaches a user record, a webhook with a bad signature / stale timestamp / repeated event id is refused, and a broken DMN document is rejected while the previous table still evaluates. The group deletes the data it creates.