Download OpenAPI specification:
Custom (non-standard) APIs of the GIW POC Identity Platform.
Scope of this document. It specifies the APIs Galaxy builds, in two groups:
The Portal ↔ Galaxy ID leg is standard OAuth 2.0 / OIDC over the token and
admin endpoints (RFC 6749 §4.3 Resource Owner Password Credentials, RFC 7009)
and is deliberately not re-specified here — see API-OVERVIEW.md §2.
Treating a standard endpoint as a bespoke Galaxy API is how integration teams
end up with a private fork of OIDC.
Authentication is POC-only. The portal authenticates over Direct Grant
(grant_type=password). RFC 9700 states the password grant MUST NOT be used
and no exception is claimed here; it is a constrained POC workaround for
captive-portal UX validation. Production authentication architecture remains
TBC and should prefer browser-based OIDC/federation. The browser /
Authorization Code + PKCE path is retained in the realm as PRODUCTION
CANDIDATE — see ../docs/AUTH-STRATEGY.md.
Status. POC. Nothing here is a production contract. Timeouts, TTLs and
tier names are POC assumptions, not SLAs — see API-REVIEW-CHECKLIST.md.
Privacy. citizenId (CCCD) appears in exactly one request body in this
whole specification, and in no response, no log field and no token claim.
See DATA-CLASSIFICATION.md.
Provider: Wi-Fi Portal. Consumer: the portal's own browser frontend.
These are the only endpoints the browser ever calls. The portal holds the
tokens; the browser holds an opaque sid cookie and nothing else. Each
successful call returns the whole outcome — identity, entitlement and
network session — because a captive portal user has one question
("am I online?") and should not have to make three round trips to answer it.
The portal forwards the credentials to Galaxy ID's token endpoint (OAuth 2.0 Resource Owner Password Credentials, confidential client), then obtains an entitlement and opens a network session before answering.
POC ONLY. Direct Grant is a constrained POC workaround; production
auth architecture is TBC and should prefer browser OIDC/federation.
See ../docs/AUTH-STRATEGY.md.
The portal never verifies a credential itself. It has no password store and no HR access; every authentication decision is Keycloak's.
The password is transited, never persisted and never logged. JavaScript strings are immutable so it cannot be zeroed; it goes out of scope when the request ends. That is the honest guarantee.
On success the response sets an opaque, HttpOnly, SameSite=Strict
sid cookie. Tokens stay server-side.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| username required | string <email> |
| password required | string <password> non-empty SECRET. Forwarded once to Galaxy ID and discarded. Never stored, logged or echoed by the portal. |
| Set-Cookie | string sid= |
| X-Correlation-ID | string Echo of the request correlation id |
| authenticated required | boolean Value: true |
| correlationId required | string |
| client | string Enum: "wifi-portal-api" "wifi-portal-employee-api" Which Galaxy ID client issued the token. |
required | object Verified token claims, for display and support. See TOKEN-CLAIMS.md. Never contains a CCCD. |
object (Entitlement) | |
object (Session) | |
| rawAccessToken | string Present only when |
{- "username": "an.nguyen@example.test",
- "password": "Passw0rd!23"
}{- "authenticated": true,
- "correlationId": "corr-82db1a70-b88d-4543-85b3-dc532b5e9386",
- "client": "wifi-portal-employee-api",
- "claims": {
- "sub": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "aud": [
- "wifi-portal-employee-api",
- "account"
], - "azp": "wifi-portal-employee-api",
- "preferred_username": "emp-12345",
- "user_type": "EMPLOYEE",
- "employee_verified": true,
- "employee_ref": "EMP-12345",
- "company": "VIETJET"
}, - "entitlement": {
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "sessionId": null,
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2026-09-21T07:06:11.076Z",
- "validUntil": "2026-09-21T11:06:11.076Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}
}, - "session": {
- "sessionId": "sess-b8183922-1faf-4f58-899c-4732c20e2ae8",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "deviceRef": "poc-40c49ae4",
- "state": "GRANTED",
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "tier": "BOOST",
- "qosClass": "HIGH",
- "grantedAt": "2026-09-21T07:06:11.090Z",
- "expiresAt": "2026-09-21T11:06:11.076Z",
- "createdAt": "2026-09-21T07:06:11.085Z",
- "updatedAt": "2026-09-21T07:06:11.090Z"
}
}Creates the account through Galaxy ID's Admin API using the portal's own
service account (scoped to manage-users, deliberately not realm-admin),
then performs an ordinary login.
Register-then-login on purpose: exactly one code path mints a session, so there is one place where credentials are checked and one place where an entitlement is issued.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| email required | string <email> |
| password required | string <password> >= 8 characters SECRET. Minimum 8 characters, checked before Galaxy ID is called. |
| displayName | string Optional. Split on whitespace into firstName / lastName. |
| Set-Cookie | string sid= |
| X-Correlation-ID | string Echo of the request correlation id |
| authenticated required | boolean Value: true |
| correlationId required | string |
| client | string Enum: "wifi-portal-api" "wifi-portal-employee-api" Which Galaxy ID client issued the token. |
required | object Verified token claims, for display and support. See TOKEN-CLAIMS.md. Never contains a CCCD. |
object (Entitlement) | |
object (Session) | |
| rawAccessToken | string Present only when |
{- "email": "an.nguyen@example.test",
- "password": "Passw0rd!23",
- "displayName": "Nguyễn Văn An"
}{- "authenticated": true,
- "correlationId": "corr-82db1a70-b88d-4543-85b3-dc532b5e9386",
- "client": "wifi-portal-employee-api",
- "claims": {
- "sub": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "aud": [
- "wifi-portal-employee-api",
- "account"
], - "azp": "wifi-portal-employee-api",
- "preferred_username": "emp-12345",
- "user_type": "EMPLOYEE",
- "employee_verified": true,
- "employee_ref": "EMP-12345",
- "company": "VIETJET"
}, - "entitlement": {
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "sessionId": null,
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2026-09-21T07:06:11.076Z",
- "validUntil": "2026-09-21T11:06:11.076Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}
}, - "session": {
- "sessionId": "sess-b8183922-1faf-4f58-899c-4732c20e2ae8",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "deviceRef": "poc-40c49ae4",
- "state": "GRANTED",
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "tier": "BOOST",
- "qosClass": "HIGH",
- "grantedAt": "2026-09-21T07:06:11.090Z",
- "expiresAt": "2026-09-21T11:06:11.076Z",
- "createdAt": "2026-09-21T07:06:11.085Z",
- "updatedAt": "2026-09-21T07:06:11.090Z"
}
}POC ONLY. See ../docs/AUTH-STRATEGY.md. The production candidate for
this journey is browser OIDC federating to an Enterprise IdP (ADR-003),
which would also remove the CCCD.
The portal forwards both fields to Galaxy ID's token endpoint against a
client bound to the custom employee-direct-grant flow. Keycloak then
calls the HR Verification API and authenticates only on MATCH + ACTIVE.
The portal is a relay here, not a decision maker: it holds no HR credential and cannot verify anyone.
citizenId is sent as its own field, never as the OAuth password
parameter — proxies, APM agents and error reporters treat password
specially, and a CCCD must stay away from that machinery.
CCCD handling: transient. Not stored, not logged, not in any token, not echoed in any response. Enforced by automated tests.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| employeeId required | string^[A-Za-z0-9._-]{3,32}$ |
| citizenId required | string^[0-9]{9,12}$ SENSITIVE PERSONAL DATA (Law 91/2025/QH15). Transient verification input only. Must not be stored, logged, cached, echoed, or placed in a token by any party. This is the only place it appears in this API. |
| Set-Cookie | string sid= |
| X-Correlation-ID | string Echo of the request correlation id |
| authenticated required | boolean Value: true |
| correlationId required | string |
| client | string Enum: "wifi-portal-api" "wifi-portal-employee-api" Which Galaxy ID client issued the token. |
required | object Verified token claims, for display and support. See TOKEN-CLAIMS.md. Never contains a CCCD. |
object (Entitlement) | |
object (Session) | |
| rawAccessToken | string Present only when |
{- "employeeId": "VJ12345",
- "citizenId": "001234567890"
}{- "authenticated": true,
- "correlationId": "corr-82db1a70-b88d-4543-85b3-dc532b5e9386",
- "client": "wifi-portal-employee-api",
- "claims": {
- "sub": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "aud": [
- "wifi-portal-employee-api",
- "account"
], - "azp": "wifi-portal-employee-api",
- "preferred_username": "emp-12345",
- "user_type": "EMPLOYEE",
- "employee_verified": true,
- "employee_ref": "EMP-12345",
- "company": "VIETJET"
}, - "entitlement": {
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "sessionId": null,
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2026-09-21T07:06:11.076Z",
- "validUntil": "2026-09-21T11:06:11.076Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}
}, - "session": {
- "sessionId": "sess-b8183922-1faf-4f58-899c-4732c20e2ae8",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "deviceRef": "poc-40c49ae4",
- "state": "GRANTED",
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "tier": "BOOST",
- "qosClass": "HIGH",
- "grantedAt": "2026-09-21T07:06:11.090Z",
- "expiresAt": "2026-09-21T11:06:11.076Z",
- "createdAt": "2026-09-21T07:06:11.085Z",
- "updatedAt": "2026-09-21T07:06:11.090Z"
}
}Always 200. An unauthenticated caller gets {"authenticated": false}
rather than a 401, because the entry screen asks this on every page load
and an error status there is noise, not information.
The network session is re-read from the Session Service so an expiry or a revocation elsewhere shows up here.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| authenticated required | boolean Value: true |
| correlationId required | string |
| client | string Enum: "wifi-portal-api" "wifi-portal-employee-api" Which Galaxy ID client issued the token. |
required | object Verified token claims, for display and support. See TOKEN-CLAIMS.md. Never contains a CCCD. |
object (Entitlement) | |
object (Session) | |
| rawAccessToken | string Present only when |
{- "authenticated": true,
- "correlationId": "string",
- "client": "wifi-portal-api",
- "claims": { },
- "entitlement": {
- "entitlementId": "string",
- "subjectId": "string",
- "sessionId": "string",
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}, - "issuedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}, - "session": {
- "sessionId": "string",
- "subjectId": "string",
- "deviceRef": "string",
- "state": "CREATED",
- "entitlementId": "string",
- "entitlementType": "string",
- "tier": "string",
- "qosClass": "string",
- "grantedAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}, - "rawAccessToken": "string"
}| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| Set-Cookie | string sid= |
| X-Correlation-ID | string Echo of the request correlation id |
| authenticated required | boolean Value: true |
| correlationId required | string |
| client | string Enum: "wifi-portal-api" "wifi-portal-employee-api" Which Galaxy ID client issued the token. |
required | object Verified token claims, for display and support. See TOKEN-CLAIMS.md. Never contains a CCCD. |
object (Entitlement) | |
object (Session) | |
| rawAccessToken | string Present only when |
{- "authenticated": true,
- "correlationId": "corr-82db1a70-b88d-4543-85b3-dc532b5e9386",
- "client": "wifi-portal-employee-api",
- "claims": {
- "sub": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "aud": [
- "wifi-portal-employee-api",
- "account"
], - "azp": "wifi-portal-employee-api",
- "preferred_username": "emp-12345",
- "user_type": "EMPLOYEE",
- "employee_verified": true,
- "employee_ref": "EMP-12345",
- "company": "VIETJET"
}, - "entitlement": {
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "sessionId": null,
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2026-09-21T07:06:11.076Z",
- "validUntil": "2026-09-21T11:06:11.076Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}
}, - "session": {
- "sessionId": "sess-b8183922-1faf-4f58-899c-4732c20e2ae8",
- "subjectId": "40c49ae4-2d43-4e5d-ad1d-8bc080074f85",
- "deviceRef": "poc-40c49ae4",
- "state": "GRANTED",
- "entitlementId": "ent-73e6bda7-de59-4be7-af37-4cc1b4bbfb30",
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "tier": "BOOST",
- "qosClass": "HIGH",
- "grantedAt": "2026-09-21T07:06:11.090Z",
- "expiresAt": "2026-09-21T11:06:11.076Z",
- "createdAt": "2026-09-21T07:06:11.085Z",
- "updatedAt": "2026-09-21T07:06:11.090Z"
}
}Revokes the network session, then performs a back-channel logout against Galaxy ID, then drops the portal session and clears the cookie.
Revoke-first is deliberate: if the IdP call fails, access is already off.
Always 200 — a logout that can fail is a logout users do not trust.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| ok | boolean |
| correlationId | string |
{- "ok": true,
- "correlationId": "string"
}| flight | string |
| idp | string |
| mode | string Value: "api" Always |
| exposeToken | boolean Demo aid flag. Must be false outside a laptop. |
{- "flight": "VJ178 · SGN→HAN",
- "idp": "Galaxy ID (Keycloak)",
- "mode": "api",
- "exposeToken": true
}Provider: HR (mocked). Consumer: Keycloak custom authenticator. System of record for employment status.
Confirms that the (employeeId, citizenId) pair matches an employee record and reports whether that employee is active and Wi-Fi eligible.
Read-only. No state changes, so a timed-out call is safe to retry. The Keycloak authenticator retries once by default.
No enumeration. "No such employee" and "wrong citizenId" return the
byte-identical body {"verified":false,"active":false,"reason":"NOT_FOUND_OR_MISMATCH"}.
Comparison is constant-time so response latency does not leak a match either.
Minimal disclosure. The response carries a verification verdict and an
opaque employeeRef — never a name, department, salary band or contact detail.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| employeeId required | string^[A-Za-z0-9._-]{3,32}$ |
| citizenId required | string^[0-9]{9,12}$ SENSITIVE PERSONAL DATA (Law 91/2025/QH15). Transient verification input only. Must not be stored, logged, cached, echoed, or placed in a token by any party. This is the only place it appears in this API. |
| X-Correlation-ID | string Echo of the request correlation id |
| verified required | boolean The id/CCCD pair matched a record |
| active required | boolean That record is an active employee |
| employeeRef | string Opaque HR reference. Safe to store; not personally identifying on its own. |
| company | string Enum: "VIETJET" "GALAXY" "SOVICO" "PARTNER-X" |
| wifiEligible | boolean |
| reason | string Enum: "NOT_FOUND_OR_MISMATCH" "EMPLOYEE_INACTIVE" |
{- "employeeId": "VJ12345",
- "citizenId": "001234567890"
}{- "verified": true,
- "active": true,
- "employeeRef": "EMP-12345",
- "company": "VIETJET",
- "wifiEligible": true
}Provider: Entitlement Service. Consumer: Wi-Fi Portal. Turns a verified identity into an access decision. Does not enforce.
Evaluates the caller's verified access token against POC policy and issues an entitlement.
The token is the input, not the body. user_type, employee_verified
and company are read from the token after signature, issuer, audience and
expiry checks. Body fields are treated as hints: a subjectId that
contradicts the token's sub is a 403, and a contradicting userType is
logged and ignored. A portal that could assert its own entitlements would
make this service decorative.
Idempotent. Repeating a call with the same Idempotency-Key (or the
same subject + session) returns the original entitlement with
replayed: true rather than minting a second one.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| Idempotency-Key | string Example: 9e115334:corr-9b1f Repeat-safe key. The same key returns the original result, not a new one. |
| subjectId | string Must equal the token |
| userType | string Enum: "CUSTOMER" "EMPLOYEE" |
| employeeVerified | boolean |
| company | string |
| sessionId | string or null |
| entitlementId required | string |
| subjectId required | string |
| sessionId | string or null |
| eligible required | boolean |
| entitlementType required | string Enum: "WIFI_EMPLOYEE_PACKAGE" "WIFI_CUSTOMER_BASIC" |
| status required | string Enum: "ACTIVE" "DENIED" |
| validFrom required | string <date-time> |
| validUntil required | string <date-time> |
object (Permission) What the subject may do — deliberately separate from why they may do it
( | |
| issuedAt | string <date-time> |
| replayed | boolean |
| X-Correlation-ID | string Echo of the request correlation id |
| entitlementId required | string |
| subjectId required | string |
| sessionId | string or null |
| eligible required | boolean |
| entitlementType required | string Enum: "WIFI_EMPLOYEE_PACKAGE" "WIFI_CUSTOMER_BASIC" |
| status required | string Enum: "ACTIVE" "DENIED" |
| validFrom required | string <date-time> |
| validUntil required | string <date-time> |
object (Permission) What the subject may do — deliberately separate from why they may do it
( | |
| issuedAt | string <date-time> |
| replayed | boolean |
{- "subjectId": "9e115334-e225-4a70-a4b7-e785030ce188",
- "userType": "EMPLOYEE",
- "employeeVerified": true,
- "company": "VIETJET",
- "sessionId": "sess-2f1c"
}{- "entitlementId": "string",
- "subjectId": "string",
- "sessionId": "string",
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}, - "issuedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}| entitlementId required | string Example: ent-6b1e5b70-1f0a-4a1d-9a9e-2f0f1f7c3a10 |
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| entitlementId required | string |
| subjectId required | string |
| sessionId | string or null |
| eligible required | boolean |
| entitlementType required | string Enum: "WIFI_EMPLOYEE_PACKAGE" "WIFI_CUSTOMER_BASIC" |
| status required | string Enum: "ACTIVE" "DENIED" |
| validFrom required | string <date-time> |
| validUntil required | string <date-time> |
object (Permission) What the subject may do — deliberately separate from why they may do it
( | |
| issuedAt | string <date-time> |
| replayed | boolean |
{- "entitlementId": "string",
- "subjectId": "string",
- "sessionId": "string",
- "eligible": true,
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "status": "ACTIVE",
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z",
- "permission": {
- "tier": "BOOST",
- "qosClass": "HIGH",
- "deviceSlots": 2,
- "sourceType": "EMPLOYEE_VERIFICATION",
- "sourceRef": "EMP-12345"
}, - "issuedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}Provider: Session / Network Access Service. Consumer: Wi-Fi Portal. Enforces a decision that Entitlement already made. Never decides.
Creates a session in state CREATED. Creation grants nothing: a session
without an entitlement carries no access.
deviceRef is an opaque portal-issued handle. MAC addresses are not
accepted: iOS 14+ and Android 15 randomise them per network, so a MAC is
not a stable device identity and must never anchor metering.
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| Idempotency-Key | string Example: 9e115334:corr-9b1f Repeat-safe key. The same key returns the original result, not a new one. |
| subjectId required | string |
| deviceRef | string or null Opaque portal handle. Never a MAC address. |
| sessionId required | string |
| subjectId required | string |
| deviceRef | string or null |
| state required | string Enum: "CREATED" "GRANTED" "REVOKED" "EXPIRED" |
| entitlementId | string or null |
| entitlementType | string or null |
| tier | string or null |
| qosClass | string or null |
| grantedAt | string or null <date-time> |
| expiresAt | string or null <date-time> |
| revokedAt | string or null <date-time> |
| createdAt required | string <date-time> |
| updatedAt required | string <date-time> |
| replayed | boolean |
| sessionId required | string |
| subjectId required | string |
| deviceRef | string or null |
| state required | string Enum: "CREATED" "GRANTED" "REVOKED" "EXPIRED" |
| entitlementId | string or null |
| entitlementType | string or null |
| tier | string or null |
| qosClass | string or null |
| grantedAt | string or null <date-time> |
| expiresAt | string or null <date-time> |
| revokedAt | string or null <date-time> |
| createdAt required | string <date-time> |
| updatedAt required | string <date-time> |
| replayed | boolean |
{- "subjectId": "9e115334-e225-4a70-a4b7-e785030ce188",
- "deviceRef": "poc-9e115334"
}{- "sessionId": "string",
- "subjectId": "string",
- "deviceRef": "string",
- "state": "CREATED",
- "entitlementId": "string",
- "entitlementType": "string",
- "tier": "string",
- "qosClass": "string",
- "grantedAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}| sessionId required | string^[A-Za-z0-9-]{1,64}$ Example: sess-2f1c8a90-4b7d-4e01-9c33-8a5f1d2e7b60 |
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| sessionId required | string |
| subjectId required | string |
| deviceRef | string or null |
| state required | string Enum: "CREATED" "GRANTED" "REVOKED" "EXPIRED" |
| entitlementId | string or null |
| entitlementType | string or null |
| tier | string or null |
| qosClass | string or null |
| grantedAt | string or null <date-time> |
| expiresAt | string or null <date-time> |
| revokedAt | string or null <date-time> |
| createdAt required | string <date-time> |
| updatedAt required | string <date-time> |
| replayed | boolean |
{- "sessionId": "string",
- "subjectId": "string",
- "deviceRef": "string",
- "state": "CREATED",
- "entitlementId": "string",
- "entitlementType": "string",
- "tier": "string",
- "qosClass": "string",
- "grantedAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}Declarative and idempotent, matching the applyAccess verb in ADR-007:
re-applying the same entitlementId is a no-op returning replayed: true.
Refuses with 422 if no entitlementId is supplied. This service never
invents access: every grant traces to an entitlement (ADR-008).
| sessionId required | string^[A-Za-z0-9-]{1,64}$ Example: sess-2f1c8a90-4b7d-4e01-9c33-8a5f1d2e7b60 |
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| entitlementId required | string |
| entitlementType | string |
| tier | string Enum: "BASIC" "BOOST" |
| qosClass | string Enum: "STANDARD" "HIGH" |
| validUntil | string <date-time> |
| sessionId required | string |
| subjectId required | string |
| deviceRef | string or null |
| state required | string Enum: "CREATED" "GRANTED" "REVOKED" "EXPIRED" |
| entitlementId | string or null |
| entitlementType | string or null |
| tier | string or null |
| qosClass | string or null |
| grantedAt | string or null <date-time> |
| expiresAt | string or null <date-time> |
| revokedAt | string or null <date-time> |
| createdAt required | string <date-time> |
| updatedAt required | string <date-time> |
| replayed | boolean |
{- "entitlementId": "ent-6b1e5b70-1f0a-4a1d-9a9e-2f0f1f7c3a10",
- "entitlementType": "WIFI_EMPLOYEE_PACKAGE",
- "tier": "BOOST",
- "qosClass": "HIGH",
- "validUntil": "2026-09-21T08:30:00.000Z"
}{- "sessionId": "string",
- "subjectId": "string",
- "deviceRef": "string",
- "state": "CREATED",
- "entitlementId": "string",
- "entitlementType": "string",
- "tier": "string",
- "qosClass": "string",
- "grantedAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}Idempotent (revokeAccess in ADR-007). Revoking twice is a no-op.
| sessionId required | string^[A-Za-z0-9-]{1,64}$ Example: sess-2f1c8a90-4b7d-4e01-9c33-8a5f1d2e7b60 |
| X-Correlation-ID | string Example: corr-9b1f2c7e-1a2b-4c3d-8e9f-0a1b2c3d4e5f Trace id propagated across Identity → HR → Entitlement → Session. Generated if absent and always echoed on the response. Never derived from a CCCD. |
| sessionId required | string |
| subjectId required | string |
| deviceRef | string or null |
| state required | string Enum: "CREATED" "GRANTED" "REVOKED" "EXPIRED" |
| entitlementId | string or null |
| entitlementType | string or null |
| tier | string or null |
| qosClass | string or null |
| grantedAt | string or null <date-time> |
| expiresAt | string or null <date-time> |
| revokedAt | string or null <date-time> |
| createdAt required | string <date-time> |
| updatedAt required | string <date-time> |
| replayed | boolean |
{- "sessionId": "string",
- "subjectId": "string",
- "deviceRef": "string",
- "state": "CREATED",
- "entitlementId": "string",
- "entitlementType": "string",
- "tier": "string",
- "qosClass": "string",
- "grantedAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "replayed": true
}Provider: giw-admin, a Keycloak custom module (RealmResourceProvider)
served by Keycloak itself. Consumers: a platform operator through the
module's own GUI, and each member company's HR system through the webhook.
MVP POC scope. Connector config, batch import, the inbound webhook and the decision-table editor sit in one module so the whole integration story can be shown in one place. ADR-012 records the target architecture — these concerns split into a separate service — and what keeping them here costs today: realm attributes have no versioning or review, the webhook secret lives beside the config rather than in a vault, and both the event log and the duplicate-suppression window are in memory and lost on restart.
Authentication on /api/* is a realm bearer token carrying
realm-management/manage-users. A master-realm admin token is rejected:
the module authenticates against the realm it serves. The GUI itself logs
in with Authorization Code + PKCE — an admin console has none of the
captive-portal constraints that forced Direct Grant on the portal.
Returns every configured member company. The webhook secret is never
included — only hasSecret, saying whether one is set.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
Array of objects (Company) |
{- "companies": [
- {
- "code": "VIETJET",
- "name": "Vietjet Air",
- "enabled": true,
- "timeoutMs": 3000,
- "employeeIdPrefix": "VJ",
- "updatedAt": "2019-08-24T14:15:22Z",
- "updatedBy": "giw-operator@giw.test",
- "hasSecret": true
}
]
}Full replace, not a merge. secret is write-only: supplying it stores a
new webhook signing secret, omitting it leaves the current one in place,
and no response anywhere returns it.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| code required | string^[A-Z0-9-]{2,24}$ Example: VIETJET Member company code. Uppercase A–Z, digits and hyphen, 2–24 characters. |
| name required | string |
| enabled | boolean Default: false |
| hrEndpoint | string |
| loyaltyEndpoint | string |
| timeoutMs | integer [ 500 .. 15000 ] Default: 3000 |
| employeeIdPrefix | string |
| secret | string Webhook signing secret. Write-only — omit to keep the current one. No response in this API returns it. |
| saved | boolean |
| code | string |
{- "name": "Vietjet Air",
- "enabled": false,
- "hrEndpoint": "string",
- "loyaltyEndpoint": "string",
- "timeoutMs": 3000,
- "employeeIdPrefix": "string",
- "secret": "string"
}{- "saved": true,
- "code": "VIETJET"
}Removes configuration and secret. Users already imported from that company are not touched — deleting a connector is not a way to delete people.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| code required | string^[A-Z0-9-]{2,24}$ Example: VIETJET Member company code. Uppercase A–Z, digits and hyphen, 2–24 characters. |
| removed | boolean |
| code | string |
{- "removed": true,
- "code": "VIETJET"
}GET {endpoint-origin}/health against each configured endpoint, using
that company's timeout. Read-only; nothing is stored. This is how a
misconfigured connector is found before an employee hits it at login.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| code required | string^[A-Z0-9-]{2,24}$ Example: VIETJET Member company code. Uppercase A–Z, digits and hyphen, 2–24 characters. |
object (Probe) | |
object (Probe) |
{- "hr": {
- "state": "UP",
- "ms": 0,
- "url": "string",
- "error": "string"
}, - "loyalty": {
- "state": "UP",
- "ms": 0,
- "url": "string",
- "error": "string"
}
}Two modes, and the difference matters. preview works out what would
happen and writes nothing; commit does it.
A row is matched in this order: CCCD link token → employee_ref →
email → create. Order is not arbitrary — a CCCD identifies a person,
an email identifies an inbox.
citizenId is converted to HMAC-SHA256(cccd, realm salt) on arrival
and the raw value is dropped. It is not stored, not logged and not
echoed in the response.
Import never sets employee_verified. A data load is not a
verification; only a live HR check at login may set that.
A preview evaluates each row against the realm as it is now, not against earlier rows in the same file. Two rows sharing a CCCD therefore preview as two creates and commit as create + link. The counts differing between the two modes is expected.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| mode | string Default: "preview" Enum: "preview" "commit"
|
| company required | string |
required | Array of objects (ImportRow) [ 1 .. 5000 ] items |
| mode | string Enum: "PREVIEW" "COMMIT" |
| company | string |
| total | integer |
object | |
Array of objects (ImportOutcome) |
{- "company": "VIETJET",
- "rows": [
- {
- "employeeId": "12345",
- "citizenId": "001199012345",
- "email": "an.nguyen@example.test",
- "fullName": "Nguyen Van An"
}
]
}{- "mode": "PREVIEW",
- "company": "string",
- "total": 0,
- "counts": {
- "CREATE": 412,
- "LINK_BY_CCCD": 38,
- "CONFLICT": 6
}, - "outcomes": [
- {
- "line": 0,
- "action": "CREATE",
- "employeeRef": "EMP-12345",
- "email": "string",
- "username": "string",
- "matchedUserId": "14a1731f-8a3c-405f-a5ed-f0a09906e2df",
- "note": "string"
}
]
}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 is base64(HMAC-SHA256(secret, "{timestamp}.{rawBody}")).
Signing the bytes as received is the point: re-serialising the parsed
object first would verify our own serialiser, not the sender.
Only events that change who can log in are applied. Events about what someone gets — a loyalty tier, a package purchase — are recorded and not applied, because writing a tier into Keycloak would make it a cache of the loyalty system, and a stale one.
POC limitation. Duplicate suppression and the event log are in memory. An event arriving while Keycloak restarts is lost, and the provider will retry a few times and give up — an employee whose departure nobody recorded. See ADR-012, T-WEBHOOK-DURABILITY.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| company required | string Example: VIETJET |
| X-GIW-Timestamp required | string Example: 1790071678 Unix seconds. Rejected outside ±5 minutes of server time. |
| X-GIW-Signature required | string
|
| X-GIW-Event-Id | string <uuid> Duplicate suppression. Providers retry; without this, applying an event twice is how a reinstated employee ends up disabled again. |
| type required | string Applied: |
| employeeRef | string Required for an identity event. |
| company | string Target company, for |
| result | string Enum: "APPLIED" "RECORDED" "DUPLICATE" "NO_MATCH" "CONFLICT" "REJECTED" |
| detail | string |
{- "type": "EMPLOYEE_TERMINATED",
- "employeeRef": "EMP-12345",
- "company": "GALAXY"
}{- "result": "APPLIED",
- "detail": "string"
}A bounded in-memory ring, most recent first. Enough to show an operator that events are arriving; not a durable event store.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| limit | integer <= 200 Default: 50 |
Array of objects (EventLogEntry) |
{- "events": [
- {
- "id": "string",
- "at": "2019-08-24T14:15:22Z",
- "company": "string",
- "type": "string",
- "outcome": "string",
- "detail": "string",
- "payload": { }
}
]
}| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| models | Array of objects |
| loadedAt | string <date-time> |
{- "models": [
- { }
], - "loadedAt": "2019-08-24T14:15:22Z"
}| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| name required | string Example: WifiEntitlement DMN model name as the engine reports it, not the filename. |
| model | string |
| file | string |
| xml | string |
{- "model": "WifiEntitlement",
- "file": "wifi-entitlement.dmn",
- "xml": "string"
}The old bytes are kept. If the submitted document does not compile they are put back and the previous runtime restored before the caller is told what went wrong — a mistyped FEEL expression must not leave a service that denies everyone.
No approval workflow. Anyone holding manage-users can change who
gets access to what, with no version history and no reviewer. This is
the same gap already recorded for POST /api/v1/decisions/reload; see
DMN-DECISION-SPEC §7.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| name required | string Example: WifiEntitlement DMN model name as the engine reports it, not the filename. |
| xml required | string The complete DMN 1.3 document |
| saved | boolean |
| model | string |
| file | string |
| reloadedAt | string <date-time> |
{- "xml": "string"
}{- "saved": true,
- "model": "WifiEntitlement",
- "file": "wifi-entitlement.dmn",
- "reloadedAt": "2019-08-24T14:15:22Z"
}For seeing what a rule change would do before saving it. Every input the
model declares must be supplied; a missing one is a 422, not a denial.
A rule set that cannot evaluate must never read as "denied" — that looks
identical to a legitimate refusal.
| realm required | string Default: "galaxy-id-poc" Keycloak realm the module is serving. |
| name required | string Example: WifiEntitlement DMN model name as the engine reports it, not the filename. |
required | object |
| decision | string One decision by name. Omit to evaluate all of them. |
| model | string |
object | |
| correlationId | string |
{- "inputs": {
- "userType": "CUSTOMER",
- "employeeVerified": false,
- "company": null,
- "memberTier": "GOLD"
}, - "decision": "string"
}{- "model": "string",
- "results": { },
- "correlationId": "string"
}Present on mock-hr-api, entitlement-service, session-service and wifi-portal.
| status required | string Value: "UP" |
| service required | string |
{- "status": "UP",
- "service": "entitlement-service"
}Forces mock-hr-api to time out or return 503, so the failure cases in section 12 of the task order can be exercised end to end.
Never ship this endpoint shape. It exists because a POC that cannot demonstrate its own failure modes has not been tested.
| mode | string Enum: "none" "timeout" "unavailable" |
| delayMs | integer |
| ok | boolean |
object |
{- "mode": "none",
- "delayMs": 4000
}{- "ok": true,
- "fault": {
- "mode": "string",
- "delayMs": 0
}
}