PII Handling Guide — Operational¶
| Scope | All OneWallet services |
| Regulation | PDPA (Thai Personal Data Protection Act B.E. 2562) + BOT data security requirements |
| Last updated | 2026-05-01 |
What Counts as PII in OneWallet¶
| Data | Category | Where stored |
|---|---|---|
| Full name | PII | user_profile.encrypted_pii (AES-256-GCM) |
| National ID / passport number | PII | user_profile.encrypted_pii (AES-256-GCM) |
| Date of birth | PII | user_profile.encrypted_pii (AES-256-GCM) |
| Phone number | PII | user_profile.encrypted_pii + phone_hash (HMAC-SHA256+pepper) + phone_masked |
| Email address | PII | user_profile.encrypted_pii |
| Home address | PII | user_profile.encrypted_pii |
| Tax ID | PII | user_profile.encrypted_pii |
| Nationality | PII | user_profile.encrypted_pii |
| National ID photo, passport photo, selfie | Biometric PII | S3 kyc-data bucket only (presigned URLs, TTL-bound) |
| OCR extraction (document fields, face match score) | Sensitive PII | kyc_verification.ocr_result (AES-256-GCM) |
userId (integer) |
Non-PII identifier | Used across all services safely |
| Transaction amounts, counterparties | Financial data | TigerBeetle ledger + pm.tx_history (by userId only) |
Golden Rules¶
- Never log PII. Log
userId(integer), not name, phone, or document numbers. See per-service rules below. - Encrypt at rest. All PII fields are encrypted as AES-256-GCM blob in
user_profile.encrypted_pii. The encryption key lives in the environment variable or Secrets Manager — never in the database or source code. - Minimize exposure. Do not pass PII to services that do not need it. PM receives
userIdonly, never name or ID number. - KYC images stay in S3 Garage. The DB stores only the S3 object key. Images are accessed via presigned URLs with 1-hour TTL. Never copy images to DB or local disk.
- PM receives no PII.
pm.*schema contains zero PII fields.pm.tx_historystoresfromName/toNamedisplay strings (denormalized from the user-suppliedfromName/toNameintent fields) anduserIdintegers. These display strings are set by the caller (Serverpod) and are user-visible labels, not extracted PII. - IPPS registration is PII-in-memory-only. When Serverpod calls the future
POST /accounts/external/ippsendpoint with decrypted PII for IPPS wallet registration, PM passes it to the IPPS adapter as a transient call parameter and does not write it topm.*.
What Each Service Sees¶
| Service | PII Access | What It Uses |
|---|---|---|
| Auth Center (Serverpod) | Full PII (encrypted blob + decryption key) | User profiles, KYC, onboarding, IPPS registration |
| Payment Manager | None — userId integer only |
Routes payments, enforces limits, records tx_history by userId |
| TigerBeetle | None — accounts identified by deterministic UUIDs | Ledger balances and transfers, no user-readable identifiers |
| KYC Service | S3 presigned URL (time-limited read) + userId/kycId |
OCR processing via Gemini, writes results (encrypted) back via Serverpod |
| Notifications Service | userId + pre-composed message from producer |
FCM delivery; does not compose message content; does not read user PII |
| Admin Panel | Masked PII for support/operator roles; decrypted PII for superadmin only | Operator review, KYC approval |
| IPPS Adapter (Phase 2B) | PII passed transiently from Serverpod via PM for registration only | IPPS wallet registration; not persisted in pm.* |
Logging Rules by Service¶
Payment Manager¶
- Can log:
intentId,userId,amount(satang integer),channel,operationType,status, error code,rqUID(IPPS reference). - Must NOT log: names, phone numbers, account numbers, full national IDs, IPPS raw request/response bodies,
metadatafields that Serverpod populates (they may containkycTierstrings that could be combined with other data for re-identification — log onlyoperationTypeand intent lifecycle events).
Auth Center (Serverpod)¶
- Can log:
userId, session token hash (last 8 chars only), event type (auth.login.success,kyc.submission.created, etc.),kycId,submissionId. - Must NOT log: OTP values, passwords, full JWT tokens, national ID numbers, full name, phone number in plaintext, OCR extraction text.
KYC Service¶
- Can log:
userId,kycId,submissionId, BullMQ job ID, OCR job status (processing,completed,failed), error codes from Gemini API. - Must NOT log: The OCR result text itself (it contains national ID, DOB, full name), Gemini API response body, presigned S3 URL (time-limited but should not be logged), face match score threshold decisions.
Notifications Service¶
- Can log:
userId, notification type/event name, FCM delivery status, message ID. - Must NOT log: Notification
titleorbodycontent (may contain account numbers, transfer amounts, or names), full Redis stream message payload.
Admin Panel (Backend / SvelteKit BFF)¶
- Can log:
actorId(admin user), action type,entityType+entityId,traceId. - Must NOT log: Decrypted PII values accessed by superadmin (name, national ID), KYC document presigned URLs.
PII Architecture — Single Source of Truth¶
public.user_profile
├── encrypted_pii ← AES-256-GCM blob: name, nationalId, DOB, phone,
│ email, address, taxId, nationality
├── phone_hash ← HMAC-SHA256(phone + pepper): used for P2P recipient lookup
├── phone_masked ← "***728106": display-only (last 6 digits)
└── name_masked ← "S*** J***": display-only
All other tables — pm.intent, pm.tx_history, pm.intent_event, pm.outbox_event, pm.psp_tx_map, TigerBeetle accounts — store only userId (integer) as a foreign key. No PII fields exist in pm.* schema. TigerBeetle accounts use deterministic UUIDs derived from account names like user.{id}.THB — no user-readable PII.
Display Fields vs. PII¶
pm.tx_history and pm.intent have from_name, to_name, and comment columns. These are not PII in the strict sense — they are user-supplied display labels passed from Serverpod to PM as part of the POST /intents body. They should not contain national IDs, phone numbers, or other regulated PII. They are user-visible transaction labels.
For IPPS transfers, psp_tx_map.receiver_display_name is the name returned by IPPS query (e.g. the PromptPay registered name). This is derived from third-party data and is stored in pm.* — it is treated as operational data, not as primary PII, since it is the receiving bank's published registered name. However, do not log it verbatim in production logs.
Masking Rules¶
| Data | Display format | Where applied |
|---|---|---|
| Phone number | ***728106 (last 6 digits) |
Flutter App, Admin Panel |
| Document/national ID | *********0123 (last 4 digits) |
Admin Panel (operator role) |
n***a@example.com |
User profile screen | |
| Name | S*** J*** |
Support role in Admin Panel |
PDPA Compliance — User Rights¶
Right to Access¶
- User can request an export of their data via the Auth Center.
- Export scope:
user_profile(decrypted PII), KYC document metadata (not image data), transaction history fromv_tx_history. - KYC images: presigned URL with TTL — user accesses via the app during the KYC process.
Right to Erasure — 3-Phase Deletion¶
On account deletion request:
| Day | Event | What happens |
|---|---|---|
| 0 | User initiates deletion | user.status → archived. Account deactivated. All sessions invalidated (Redis keys deleted). No new payments possible. |
| 7 (configurable) | Anonymization | PII replaced with one-way hashes. encrypted_pii blob is overwritten with anonymized data. phone_hash is re-hashed with a deletion pepper (different from search pepper). |
| 30 (configurable) | Full deletion | DB rows for user_profile, kyc_verification, KYC documents in S3. |
| Immediate (≥5 years old) | KYC documents | S3 objects deleted at day 30 if ≥5 years old; otherwise marked pending_deletion for deferred cleanup when retention period expires. |
TigerBeetle: Transaction history is retained permanently (7-year financial audit requirement). The user_data_64 field on TB transfers stores the userId integer. After anonymization, the userId is retained in TB for audit purposes but the user's PII in PostgreSQL is deleted — connecting the two requires the userId integer, which becomes a non-reversible pseudonym after PII deletion.
IPPS wallet: When user.status → archived, the IPPS wallet deactivation flow (POST /deactivate-wallet-user) must be called. Current status: pending resolution of compliance question A2 in 2026-04-29-ipps-questions.md.
Right to Data Portability¶
- Currently not implemented. Planned as a future compliance feature.
- Mechanism will be: Auth Center generates a structured export (JSON/PDF) containing transaction history + profile data on user request.
Consent Management¶
- User consent to data processing is collected at registration.
- Consent record stored in:
user.data_processing_consent_at(timestamp of consent),user.consent_version(version of privacy policy accepted). - Purpose limitation: data used only for e-money payment services. Not shared with third parties without consent, except as required by BOT/AMLO regulation.
When Adding New PII Fields¶
Before adding any new PII field to any table:
- Is encryption needed? If the field belongs in
user_profile.encrypted_pii, add it to the AES-GCM blob schema — do NOT add a plaintext column. If it belongs elsewhere, add column-level encryption. - Is it excluded from logs? Add the field name to the log sanitization list in the relevant service's logger config.
- Is it masked in API responses? Update response serializers to return masked version.
- Is it included in the deletion flow? Update the account deletion procedure in
RegistrationService/KycService. - Does it need PDPA consent notice update? Review the data processing notice with the compliance team.
- Does it exist in
pm.*? If yes, this is a design violation — PII must not enterpm.*. Redesign the data flow. - Is it passed to PM? If yes, this is a design violation — PM accepts
userIdonly. Redesign.
S3 KYC Document Access¶
KYC documents (national ID photos, selfies) are stored in the kyc-data S3 bucket:
- Write path: Flutter uploads directly to S3 via presigned
PUTURL (1-hour TTL) returned by Serverpod. Serverpod generates the presigned URL without ever seeing the file content. - Read path: Serverpod generates presigned
GETURL (1-hour TTL) for operator review in Admin Panel. The presigned URL is not logged. - Encryption: SSE-S3 currently; migration to SSE-KMS planned for Month 3.
- Lifecycle: S3 lifecycle policy for automatic deletion after 5 years is planned but not yet configured (tracked as open item in
docs/COMPLIANCE.md). - Path format: Currently
kyc/{kycId}/{filename}—userIdis NOT in the S3 path, making bulk per-user operations require a DB join. AddinguserIdto the path prefix is planned.
PII Access Matrix (Admin Roles)¶
| Role | encrypted_pii |
ocr_result |
KYC docs (S3) | Transaction history |
|---|---|---|---|---|
user |
Own data only (via app, decrypted) | No | No | Own only |
support |
No | No | No | View (masked names) |
operator |
No | Yes (decrypted for KYC review) | Yes (presigned URL) | View |
finance |
No | No | No | View + refund initiation |
superadmin |
Yes (decrypted) | Yes (decrypted) | Yes (presigned URL) | Full access |
All access is logged in admin_audit_log with actorId, action, entityType+entityId, timestamp, and traceId.