Data Classification — GIW POC Identity Platform

Status: POC · Date: 2026-09-21 Legal basis: Luật Bảo vệ dữ liệu cá nhân 91/2025/QH15 · Nghị định 356/2025/NĐ-CP


1. Classes

Class Definition Handling
PUBLIC Safe to disclose No control
INTERNAL Operational, not personal Access-controlled, loggable
PERSONAL Dữ liệu cá nhân cơ bản Consent, minimisation, retention limit; loggable only as an id
SENSITIVE Dữ liệu cá nhân nhạy cảm (Art. 3) Explicit consent, encryption in transit, never logged, strict retention
SECRET Security credential Never logged, never in a repository, rotatable

2. Field register

Field Class Source Purpose Persisted where Retention Masking Logging
employeeId PERSONAL User input on the employee form Key for HR lookup Nowhere. Not written to Keycloak; held in memory for one request Request lifetime Not masked in logs — see note Allowed (id only)
citizenId (CCCD) — login path SENSITIVE User input on the employee form One-time verification against HR Nowhere. Not in Keycloak, not in any service, not in any cache Request lifetime only Rendered as type=password; never echoed FORBIDDEN — enforced by automated test
citizenId (CCCD) — import path SENSITIVE CSV supplied by a member company Retrievable identifier, on operator instruction Keycloak user attribute citizen_id, in the clear Life of the account Not logged; not in any token FORBIDDEN in logs — enforced by automated test
passportId — import path SENSITIVE CSV supplied by a member company Identifier for staff a CCCD does not cover Keycloak user attribute passport_id, in the clear Life of the account Not logged; not in any token; not a matching key FORBIDDEN in logs
memberRef INTERNAL Loyalty API response Trace an entitlement to its loyalty origin Entitlement store (in memory, POC); not in a token TTL of the entitlement None needed Allowed
memberTier INTERNAL Loyalty API response Decision table input Not persisted — re-fetched every login Request lifetime None needed Allowed
email (to loyalty) PERSONAL Token claim, sent as the lookup key Find an existing member Nowhere — compared and dropped by the loyalty service Request lifetime Not logged by either side FORBIDDEN in loyalty logs
employeeRef INTERNAL HR API response Stable, non-identifying link key Keycloak user attribute employee_ref; access token claim Life of the user record None needed Allowed
company INTERNAL HR API response Entitlement eligibility Keycloak user attribute; token claim Life of the user record None needed Allowed
employeeStatus / active INTERNAL HR API response Authentication gate Not persisted — re-fetched every login Request lifetime — Allowed
wifiEligible INTERNAL HR API response Authentication gate Not persisted Request lifetime — Allowed
email PERSONAL Self-registration Account identity, password reset Keycloak user record Until account deletion Mask in logs if ever logged Avoid; use sub
phone PERSONAL Not collected in this POC — — — — —
Keycloak sub PERSONAL (pseudonymous) Keycloak Stable subject id across services Keycloak; entitlement store; session store Life of the user record None Allowed — this is the right log key
sessionId INTERNAL Session Service Access session handle Session store (in memory, POC) Until revoked/expired None Allowed
entitlementId INTERNAL Entitlement Service Decision handle, audit trail Entitlement store (in memory, POC) TTL of the entitlement None Allowed
deviceRef INTERNAL Portal-generated opaque handle Device correlation within a session Session store Session lifetime None Allowed
correlationId INTERNAL Generated corr-<uuid4> Cross-service trace Logs of every service Log retention None Allowed — this is its whole job
access_token SECRET Keycloak Bearer credential Portal server-side session map; browser holds only an opaque sid cookie 900 s Never printed FORBIDDEN
refresh_token SECRET Keycloak Renew access Portal server-side session map only SSO idle 1800 s Never printed FORBIDDEN
id_token SECRET Keycloak Nonce check, logout hint Portal server-side session map Session lifetime Never printed FORBIDDEN
HR_API_KEY SECRET .env Service-to-service auth .env (git-ignored) and container env Rotate on exposure — FORBIDDEN
SESSION_API_KEY SECRET .env Service-to-service auth .env (git-ignored) and container env Rotate on exposure — FORBIDDEN
KC_ADMIN_PASSWORD SECRET .env Bootstrap only .env and container env Rotate before any shared environment — FORBIDDEN
PKCE code_verifier SECRET Portal Binds the authorization code Portal memory, single use One request — FORBIDDEN
OIDC state / nonce INTERNAL Portal CSRF and replay defence Portal memory, single use One request — Allowed

Note on employeeId logging. It is personal data and it is logged (HR access log, authenticator decision log). That is a deliberate trade: without it, a failed verification cannot be investigated. It is the minimum that makes the system supportable. citizenId is not, and is not logged.

3. The CCCD lifecycle, in full

Two paths reach a CCCD and they are not treated the same. Keeping them apart is the whole of this section: the number a person types to prove who they are is discarded; the number a member company sends in a staff list is kept.

3a. Login path — discarded

browser form field  (type=password, autocomplete=off)
        │  HTTPS in production; plain HTTP on localhost for the POC
        ▼
Keycloak custom authenticator
        │  local variable only — no field, no session note, no user attribute
        ▼
HR Verification API request body
        │  constant-time comparison against synthetic records
        ▼
discarded

Points where it could leak, and what stops it:

Leak path Control
Log line in the authenticator Every log statement takes corr + employeeId only; the client class documents this and never interpolates the body
Exception message echoing the request HrVerificationClient catches and logs e.getClass().getSimpleName(), never e.getMessage()
Keycloak user attribute Authenticator writes four named attributes; a comment marks the omission as deliberate
Access token giw-identity scope maps four attributes; the CCCD is not among them and is not stored to be mapped
HR API response Response is built from the matched record, never echoed from the request — asserted by test
Error body The standard error model has no field for it; messages are fixed strings
Keycloak event log Events carry giw_correlation_id and giw_employee_ref only
Browser autofill / history Field is type=password with autocomplete="off"; not echoed back on a failed attempt

Automated tests enforce this and fail the suite on regression:

  • "no CCCD value appears in any container log" — greps all five container logs for every test CCCD.
  • "a login never writes a CCCD to the user record" — the authenticator's own path stays clean regardless of what import does.

3b. Import path — stored in the clear

CSV from a member company
        │  operator pastes or uploads it in giw-admin
        ▼
UserImporter.stamp()
        │  HMAC-SHA256 → cccd_link      (the matching key)
        │  raw value    → citizen_id    (the retrievable copy)
        ▼
Keycloak user attribute, for the life of the account

This reverses the original design on an explicit instruction — ADR-013. What that buys, and what it costs, is argued there rather than here. What matters for classification:

Login path Import path
Where the number comes from the person, proving identity a member company, in a list
Kept? no yes, citizen_id, in the clear
Reaches a token? no no — giw-identity maps four attributes, this is not one
Reaches a log? no no
Written when malformed? n/a no — ^[0-9]{9,12}$ or nothing

cccd_link is still written and matching still runs on it. The raw value is data the platform holds, not a key anything depends on, so deleting the column later breaks no existing link.

passport_id follows the same rule and goes one step further: there is no passport_link, because a passport is not a matching key at all. The match order is CCCD → employee_ref → email and stays that way. Letting a spreadsheet column assert "this is the same person" by passport number is an identity-linking decision, tracked as T-LINK, not a field to be added quietly.

Open, and not closed by ADR-013: Vietnamese law on personal data (Nghị định 13/2023/NĐ-CP, and the consent requirement noted in §5) applies to a stored national ID in a way it does not apply to one held for the length of a request. Nothing in this POC uses a real CCCD, so nothing is at risk today; production needs the Legal + Security + HR review that the project has recorded as pending from the start.

4. Test data

All HR records in mock-hr-api/src/employees.json are synthetic. No real person, no real employee record, no real citizen ID. The file carries a _comment saying so.

5. Controller / Processor — unresolved

Question Status
Who is the Data Controller for passenger identity — Galaxy Holdings or Vietjet? TBC (decision D7)
Who is the Controller for employee verification data? TBC — likely the employing entity, not Galaxy
Is Galaxy a Processor when it calls HR? TBC
Is a DPIA required before the POC touches real HR data? Assume yes. This POC uses synthetic data specifically to avoid needing one.

This POC must not be pointed at a real HR system until D7 is decided.

6. Production gaps

Gap Impact
Entitlement and session stores are in memory No retention policy, no erasure capability. Blocks any real-data run.
No encryption at rest Keycloak's Postgres volume is unencrypted.
No consent capture The POC records no consent artefact. Law 91/2025 requires one for the CCCD path.
No data subject access / erasure endpoint Required in production.
Plain HTTP Acceptable on localhost only.

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