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

  1. CCCD link token β€” the same person, even under a different email
  2. employee_ref β€” already imported before
  3. email β€” an existing Galaxy ID customer who turns out to be staff
  4. 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 hrEndpoint from the flow's authenticatorConfig, not from here. Editing an endpoint on this screen does not change the address Keycloak actually calls at login, and test probes 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 as DEFERRED-TASKS D5.

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.

GIW POC Identity Platform Β· local demo Β· not production Β· generated from the repository