Перейти к содержанию

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

  1. Never log PII. Log userId (integer), not name, phone, or document numbers. See per-service rules below.
  2. 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.
  3. Minimize exposure. Do not pass PII to services that do not need it. PM receives userId only, never name or ID number.
  4. 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.
  5. PM receives no PII. pm.* schema contains zero PII fields. pm.tx_history stores fromName/toName display strings (denormalized from the user-supplied fromName/toName intent fields) and userId integers. These display strings are set by the caller (Serverpod) and are user-visible labels, not extracted PII.
  6. IPPS registration is PII-in-memory-only. When Serverpod calls the future POST /accounts/external/ipps endpoint with decrypted PII for IPPS wallet registration, PM passes it to the IPPS adapter as a transient call parameter and does not write it to pm.*.

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 NoneuserId 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, metadata fields that Serverpod populates (they may contain kycTier strings that could be combined with other data for re-identification — log only operationType and 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 title or body content (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)
Email 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 from v_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.
  • 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 enter pm.*. Redesign the data flow.
  • Is it passed to PM? If yes, this is a design violation — PM accepts userId only. 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 PUT URL (1-hour TTL) returned by Serverpod. Serverpod generates the presigned URL without ever seeing the file content.
  • Read path: Serverpod generates presigned GET URL (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}userId is NOT in the S3 path, making bulk per-user operations require a DB join. Adding userId to 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.