Who accessed my KYC data — I want to know. Wise-transfer audit log gap (PR #651 VII F2)
Pieter (34) runs an affiliate-marketing studio in Rotterdam Kralingen — TikTok @pieter.foodtech 62k followers, restaurant SaaS niche. 19 months as thMenu affiliate, ~80 restaurants/month, ~€1200/month commission. Mid-May 2026 read industry news of adjacent restaurant-POS SaaS contractor-side breach: internal staffer decrypted KYC fields 22 months + exfiltrated, no log. Pieter thought of his own KYC: BSN (Dutch national ID), IBAN (NL91 ABNA...), full address. Filed GDPR Article 15 SAR: **"I want to know which staff accessed my KYC fields, when, and for what purpose in the last 24 months."** thMenu engineering forensik: SELECT FROM staff_audit_logs WHERE entity_kind = affiliate_kyc AND entity_id = pieter_id → **0 rows**. Confused stare. Pieter received 25 Wise transfers in 18-month window, each should require KYC decrypt, no audit log entries. apps/web-superadmin/src/lib/wise.ts:initiateTransfer() called supabase.rpc(decrypt_kyc_field) which just ran pgp_sym_decrypt + returned plaintext. **NO AUDIT LOG INSERT.** Classic false assumption: "we encrypt at rest, we re covered." Encryption protects ONE scenario: cold backup leak, R2 bucket misconfig, disk theft. If key stolen or authorized caller decrypts, plaintext flows openly — but who called, when, why is invisible. SOC 2 Type II + GDPR Article 5(1)(f) + EU NIS2 all require "sensitive data accesses MUST be logged AND auditable." **PR #651 batch VII F2** 3-layer fix: (1) decrypt_kyc_field RPC SECURITY DEFINER with audit log INSERT BEFORE decrypt (row exists even if decrypt fails — "who tried" stays visible) + caller role check + auth.uid() match validation. (2) Callers MUST pass required purpose param: wise_transfer:txn_id format — what was it for, which transfer, full forensic trail. (3) kyc_access_audit_log table RLS-protected: SELECT policy USING (affiliate_id = auth.uid()) — affiliate sees own access log, service-role bypass via JWT role check for Synaltix compliance dashboard. **Backfill impossible** — 18-month window unknown patterns. Compensating controls: (1) Wise dashboard staff_id cross-reference for Pieter s 25 transfers; (2) ENV.AFFILIATE_KYC_KEY rotation new key + decrypt-with-old + encrypt-with-new + old key revoke; (3) internal SSO log review for unusual patterns (50+ KYC accesses in 1 hour — none found). Pieter delivered honest disclosure + 6-month Pro tier + Hall of Fame credit. Pattern: **every encrypted field s decrypt path goes through SECURITY DEFINER RPC with pre-decrypt audit log INSERT + required purpose param + RLS-protected log table**. Implementation checklist: SECURITY DEFINER RPC + INSERT BEFORE decrypt + required purpose param + RLS log table + quarterly compliance deviation scan + annual KYC_KEY rotation (NIST SP 800-57). 2 months post-fix Pieter saw dashboard "KYC Access Log" tab with one entry — his own Wise transfer, accessor synaltix.payout-ops@synaltix.io, purpose wise_transfer:txn_abc123. Transparency explicit + real-time.