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. |