# PDP Patient Data Encryption Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Enkripsi field PII pasien (`m_patient`, `m_patientaddress`) dan data medis hasil lab (12 tabel) menggunakan AES-256-GCM dengan key dari `.env`. Patient search tetap berjalan via trigram blind index; hasil lab cukup encrypt/decrypt langsung. **Architecture:** Key enkripsi disimpan di `.env` dan di-load di `index.php`. Library `Ibl_encryptor` menangani encrypt/decrypt (AES-256-GCM) dan pembuatan trigram token (HMAC-SHA256) untuk pencarian partial. Kolom lama tetap ada selama masa migrasi, kolom `_enc` menyimpan ciphertext, kolom `_bidx` menyimpan JSON array token trigram untuk field yang digunakan search. **Tech Stack:** PHP CodeIgniter 3, MySQL 5.7+, OpenSSL (AES-256-GCM), `hash_hmac` SHA-256 --- ## Field yang Dienkripsi ### `one_lab.m_patient` | Field | Kolom _enc | Kolom _bidx (untuk search) | |-------|-----------|---------------------------| | M_PatientName | M_PatientName_enc | M_PatientName_bidx | | M_PatientHP | M_PatientHP_enc | M_PatientHP_bidx | | M_PatientDOB | M_PatientDOB_enc | M_PatientDOB_bidx | | M_PatientEmail | M_PatientEmail_enc | — | | M_PatientPhone | M_PatientPhone_enc | — | | M_PatientPOB | M_PatientPOB_enc | — | | M_PatientIDNumber | M_PatientIDNumber_enc | — | | M_PatientNIK | M_PatientNIK_enc | — | | M_PatientNIP | M_PatientNIP_enc | — | ### `one_lab.m_patientaddress` | Field | Kolom _enc | Kolom _bidx | |-------|-----------|-------------| | M_PatientAddressDescription | M_PatientAddressDescription_enc | M_PatientAddressDescription_bidx | | M_PatientAddressEmail | M_PatientAddressEmail_enc | — | | M_PatientAddressPhone | M_PatientAddressPhone_enc | — | --- ## File yang Dibuat / Dimodifikasi | File | Action | Tanggung Jawab | |------|--------|----------------| | `.env` | Create | Menyimpan IBL_ENCRYPT_KEY dan IBL_ENCRYPT_SEARCH_KEY | | `index.php` | Modify | Load `.env` ke `$_ENV` sebelum CI bootstrap | | `application/libraries/Ibl_encryptor.php` | Create | AES-256-GCM encrypt/decrypt + trigram blind index | | `sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql` | Create | ALTER TABLE tambah kolom _enc dan _bidx | | `scripts/migrate_encrypt_patient.php` | Create | Batch migration script enkripsi 178K data lama | | `application/controllers/mockup/fo/ibl_registration/Patient.php` | Modify | search(), add_new(), edit() pakai enkripsi | --- ## Task 1: Buat `.env` dan Load di `index.php` **Files:** - Create: `.env` - Modify: `index.php` (tambah sebelum `require_once BASEPATH.'core/CodeIgniter.php';`) - [ ] **Step 1: Buat file `.env`** ``` IBL_ENCRYPT_KEY=<32-byte-hex-random> IBL_ENCRYPT_SEARCH_KEY=<32-byte-hex-random> ``` Generate key-nya dengan: ```bash php -r "echo bin2hex(random_bytes(32)) . PHP_EOL; echo bin2hex(random_bytes(32)) . PHP_EOL;" ``` Isi `.env` dengan output dua baris tersebut: ``` IBL_ENCRYPT_KEY=a1b2c3d4... # baris 1 IBL_ENCRYPT_SEARCH_KEY=e5f6g7h8... # baris 2 ``` - [ ] **Step 2: Tambahkan `.env` ke `.gitignore`** Cek apakah `.gitignore` sudah ada: ```bash cat .gitignore 2>/dev/null || echo "tidak ada" ``` Tambahkan baris: ``` .env ``` - [ ] **Step 3: Modifikasi `index.php` untuk load `.env`** Buka `index.php`. Temukan baris terakhir sebelum: ```php require_once BASEPATH.'core/CodeIgniter.php'; ``` Tambahkan SEBELUM baris tersebut: ```php // Load .env $_env_file = __DIR__ . '/.env'; if (file_exists($_env_file)) { foreach (file($_env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $_env_line) { if (strpos(trim($_env_line), '#') === 0) continue; [$_env_k, $_env_v] = array_map('trim', explode('=', $_env_line, 2)); if ($_env_k !== '') $_ENV[$_env_k] = $_env_v; } unset($_env_file, $_env_line, $_env_k, $_env_v); } ``` - [ ] **Step 4: Verifikasi key terbaca** ```bash php -r " \$_ENV = []; foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as \$l) { [\$k,\$v] = explode('=', \$l, 2); \$_ENV[trim(\$k)] = trim(\$v); } echo 'ENCRYPT_KEY len: ' . strlen(\$_ENV['IBL_ENCRYPT_KEY']) . PHP_EOL; echo 'SEARCH_KEY len: ' . strlen(\$_ENV['IBL_ENCRYPT_SEARCH_KEY']) . PHP_EOL; " ``` Expected: ``` ENCRYPT_KEY len: 64 SEARCH_KEY len: 64 ``` - [ ] **Step 5: Commit** ```bash git add index.php .gitignore git commit -m "TASKCODE - load .env encryption keys di bootstrap" ``` > Ganti TASKCODE dengan kode task dari user. Jangan commit file `.env`. --- ## Task 2: Library `Ibl_encryptor` **Files:** - Create: `application/libraries/Ibl_encryptor.php` - [ ] **Step 1: Buat library** ```php key = hex2bin($raw); $this->search_key = hex2bin($raw_s); } // Enkripsi plaintext → base64(iv + tag + ciphertext) public function encrypt($plaintext) { if ($plaintext === null || $plaintext === '') return null; $iv = random_bytes(12); $tag = ''; $ct = openssl_encrypt($plaintext, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', $this->tag_length); if ($ct === false) return null; return base64_encode($iv . $tag . $ct); } // Dekripsi base64(iv + tag + ciphertext) → plaintext public function decrypt($ciphertext) { if ($ciphertext === null || $ciphertext === '') return null; $raw = base64_decode($ciphertext); if (strlen($raw) < 12 + $this->tag_length) return null; $iv = substr($raw, 0, 12); $tag = substr($raw, 12, $this->tag_length); $ct = substr($raw, 12 + $this->tag_length); $pt = openssl_decrypt($ct, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv, $tag); return $pt === false ? null : $pt; } // Hasilkan JSON array token trigram (untuk partial search) // Input: string value, Output: JSON string dari array HMAC token public function search_bidx($value) { if ($value === null || $value === '') return null; $norm = mb_strtolower(trim($value), 'UTF-8'); $tokens = []; $len = mb_strlen($norm, 'UTF-8'); if ($len <= 3) { $tokens[] = $this->_token($norm); } else { for ($i = 0; $i <= $len - 3; $i++) { $tokens[] = $this->_token(mb_substr($norm, $i, 3, 'UTF-8')); } } return json_encode(array_values(array_unique($tokens))); } // Hasilkan token trigram dari query string (untuk WHERE IN saat search) // Kembalikan array token (bukan JSON) public function query_tokens($query) { if ($query === null || $query === '') return []; $norm = mb_strtolower(trim($query), 'UTF-8'); $tokens = []; $len = mb_strlen($norm, 'UTF-8'); if ($len <= 3) { $tokens[] = $this->_token($norm); } else { for ($i = 0; $i <= $len - 3; $i++) { $tokens[] = $this->_token(mb_substr($norm, $i, 3, 'UTF-8')); } } return array_values(array_unique($tokens)); } private function _token($str) { return hash_hmac('sha256', $str, $this->search_key); } } ``` - [ ] **Step 2: Test library via CLI** Buat file sementara `scripts/test_encryptor.php`: ```php encrypt($plain); $pt = $enc->decrypt($ct); assert($pt === $plain, "FAIL: decrypt mismatch"); echo "encrypt/decrypt: OK\n"; // Test null assert($enc->encrypt(null) === null, "FAIL: null encrypt"); assert($enc->decrypt(null) === null, "FAIL: null decrypt"); echo "null handling: OK\n"; // Test trigram tokens $bidx = json_decode($enc->search_bidx('BUDI'), true); $qtok = $enc->query_tokens('udi'); // partial search "udi" assert(in_array($qtok[0], $bidx), "FAIL: token 'udi' tidak ada di bidx BUDI"); echo "trigram search: OK\n"; echo "Semua test passed.\n"; ``` Jalankan: ```bash php scripts/test_encryptor.php ``` Expected: ``` encrypt/decrypt: OK null handling: OK trigram search: OK Semua test passed. ``` - [ ] **Step 3: Hapus file test sementara** ```bash rm scripts/test_encryptor.php ``` - [ ] **Step 4: Commit** ```bash git add application/libraries/Ibl_encryptor.php git commit -m "TASKCODE - tambah library Ibl_encryptor AES-256-GCM + trigram blind index" ``` --- ## Task 3: SQL Migration — Tambah Kolom `_enc` dan `_bidx` **Files:** - Create: `sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql` - [ ] **Step 1: Buat file SQL** ```sql -- UU PDP: tambah kolom enkripsi PII pasien -- Kolom lama TIDAK dihapus (backward compat selama migrasi) -- Setelah semua data termigrasi dan kode diupdate, kolom lama bisa di-drop di sprint berikutnya -- m_patient: kolom _enc untuk semua field PII ALTER TABLE one_lab.m_patient ADD COLUMN M_PatientName_enc TEXT NULL AFTER M_PatientName, ADD COLUMN M_PatientName_bidx MEDIUMTEXT NULL AFTER M_PatientName_enc, ADD COLUMN M_PatientHP_enc TEXT NULL AFTER M_PatientHP, ADD COLUMN M_PatientHP_bidx MEDIUMTEXT NULL AFTER M_PatientHP_enc, ADD COLUMN M_PatientDOB_enc TEXT NULL AFTER M_PatientDOB, ADD COLUMN M_PatientDOB_bidx MEDIUMTEXT NULL AFTER M_PatientDOB_enc, ADD COLUMN M_PatientEmail_enc TEXT NULL AFTER M_PatientEmail, ADD COLUMN M_PatientPhone_enc TEXT NULL AFTER M_PatientPhone, ADD COLUMN M_PatientPOB_enc TEXT NULL AFTER M_PatientPOB, ADD COLUMN M_PatientIDNumber_enc TEXT NULL AFTER M_PatientIDNumber, ADD COLUMN M_PatientNIK_enc TEXT NULL AFTER M_PatientNIK, ADD COLUMN M_PatientNIP_enc TEXT NULL AFTER M_PatientNIP; -- m_patientaddress: kolom _enc untuk field PII ALTER TABLE one_lab.m_patientaddress ADD COLUMN M_PatientAddressDescription_enc TEXT NULL AFTER M_PatientAddressDescription, ADD COLUMN M_PatientAddressDescription_bidx MEDIUMTEXT NULL AFTER M_PatientAddressDescription_enc, ADD COLUMN M_PatientAddressEmail_enc TEXT NULL AFTER M_PatientAddressEmail, ADD COLUMN M_PatientAddressPhone_enc TEXT NULL AFTER M_PatientAddressPhone; ``` - [ ] **Step 2: Jalankan SQL di devone** ```bash ssh devone "mysql one_lab < /home/one/project/one/one-api-lab/sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql" ``` Atau jika file belum diupload, jalankan langsung: ```bash ssh devone "mysql -e \" ALTER TABLE one_lab.m_patient ADD COLUMN M_PatientName_enc TEXT NULL AFTER M_PatientName, ADD COLUMN M_PatientName_bidx MEDIUMTEXT NULL AFTER M_PatientName_enc, ADD COLUMN M_PatientHP_enc TEXT NULL AFTER M_PatientHP, ADD COLUMN M_PatientHP_bidx MEDIUMTEXT NULL AFTER M_PatientHP_enc, ADD COLUMN M_PatientDOB_enc TEXT NULL AFTER M_PatientDOB, ADD COLUMN M_PatientDOB_bidx MEDIUMTEXT NULL AFTER M_PatientDOB_enc, ADD COLUMN M_PatientEmail_enc TEXT NULL AFTER M_PatientEmail, ADD COLUMN M_PatientPhone_enc TEXT NULL AFTER M_PatientPhone, ADD COLUMN M_PatientPOB_enc TEXT NULL AFTER M_PatientPOB, ADD COLUMN M_PatientIDNumber_enc TEXT NULL AFTER M_PatientIDNumber, ADD COLUMN M_PatientNIK_enc TEXT NULL AFTER M_PatientNIK, ADD COLUMN M_PatientNIP_enc TEXT NULL AFTER M_PatientNIP; ALTER TABLE one_lab.m_patientaddress ADD COLUMN M_PatientAddressDescription_enc TEXT NULL AFTER M_PatientAddressDescription, ADD COLUMN M_PatientAddressDescription_bidx MEDIUMTEXT NULL AFTER M_PatientAddressDescription_enc, ADD COLUMN M_PatientAddressEmail_enc TEXT NULL AFTER M_PatientAddressEmail, ADD COLUMN M_PatientAddressPhone_enc TEXT NULL AFTER M_PatientAddressPhone; \"" ``` - [ ] **Step 3: Verifikasi kolom terbentuk** ```bash ssh devone "mysql -e 'SHOW COLUMNS FROM one_lab.m_patient LIKE \"%_enc\";'" ``` Expected: 9 baris dengan kolom-kolom `_enc`. - [ ] **Step 4: Commit SQL file** ```bash git add sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql git commit -m "TASKCODE - SQL: tambah kolom _enc dan _bidx untuk PII pasien" ``` --- ## Task 4: Migration Script — Enkripsi Data Lama (178K rows) **Files:** - Create: `scripts/migrate_encrypt_patient.php` Script ini dijalankan **sekali** di server via CLI. Proses 500 baris per batch. - [ ] **Step 1: Buat migration script** ```php PDO::ERRMODE_EXCEPTION ]); // --- Migrate m_patient --- echo "=== Migrasi m_patient ===\n"; $offset = 0; $batch = 500; $total = 0; while (true) { $rows = $pdo->query("SELECT M_PatientID, M_PatientName, M_PatientHP, M_PatientDOB, M_PatientEmail, M_PatientPhone, M_PatientPOB, M_PatientIDNumber, M_PatientNIK, M_PatientNIP FROM m_patient WHERE M_PatientName_enc IS NULL LIMIT {$batch}")->fetchAll(PDO::FETCH_ASSOC); if (empty($rows)) break; $stmt = $pdo->prepare("UPDATE m_patient SET M_PatientName_enc = ?, M_PatientName_bidx = ?, M_PatientHP_enc = ?, M_PatientHP_bidx = ?, M_PatientDOB_enc = ?, M_PatientDOB_bidx = ?, M_PatientEmail_enc = ?, M_PatientPhone_enc = ?, M_PatientPOB_enc = ?, M_PatientIDNumber_enc = ?, M_PatientNIK_enc = ?, M_PatientNIP_enc = ? WHERE M_PatientID = ?"); foreach ($rows as $row) { $dob = $row['M_PatientDOB'] ? date('d-m-Y', strtotime($row['M_PatientDOB'])) : ''; $stmt->execute([ $enc->encrypt($row['M_PatientName']), $enc->search_bidx($row['M_PatientName']), $enc->encrypt($row['M_PatientHP']), $enc->search_bidx($row['M_PatientHP']), $enc->encrypt($dob), $enc->search_bidx($dob), $enc->encrypt($row['M_PatientEmail']), $enc->encrypt($row['M_PatientPhone']), $enc->encrypt($row['M_PatientPOB']), $enc->encrypt($row['M_PatientIDNumber']), $enc->encrypt($row['M_PatientNIK']), $enc->encrypt($row['M_PatientNIP']), $row['M_PatientID'], ]); $total++; } echo " {$total} rows done...\n"; } echo "m_patient selesai: {$total} rows\n"; // --- Migrate m_patientaddress --- echo "=== Migrasi m_patientaddress ===\n"; $total = 0; while (true) { $rows = $pdo->query("SELECT M_PatientAddressID, M_PatientAddressDescription, M_PatientAddressEmail, M_PatientAddressPhone FROM m_patientaddress WHERE M_PatientAddressDescription_enc IS NULL LIMIT {$batch}")->fetchAll(PDO::FETCH_ASSOC); if (empty($rows)) break; $stmt = $pdo->prepare("UPDATE m_patientaddress SET M_PatientAddressDescription_enc = ?, M_PatientAddressDescription_bidx = ?, M_PatientAddressEmail_enc = ?, M_PatientAddressPhone_enc = ? WHERE M_PatientAddressID = ?"); foreach ($rows as $row) { $stmt->execute([ $enc->encrypt($row['M_PatientAddressDescription']), $enc->search_bidx($row['M_PatientAddressDescription']), $enc->encrypt($row['M_PatientAddressEmail']), $enc->encrypt($row['M_PatientAddressPhone']), $row['M_PatientAddressID'], ]); $total++; } echo " {$total} rows done...\n"; } echo "m_patientaddress selesai: {$total} rows\n"; echo "=== Migrasi selesai ===\n"; ``` - [ ] **Step 2: Commit script** ```bash git add scripts/migrate_encrypt_patient.php git commit -m "TASKCODE - migration script enkripsi batch data PII pasien" ``` - [ ] **Step 3: Upload ke server dan jalankan** > Jalankan setelah Task 3 (kolom sudah ada) dan Task 2 (library sudah ada di server) selesai diupload. ```bash # Upload dulu via upload script bash scripts/upload_ibl_committed_files.sh # Jalankan migration di server ssh devone "cd /home/one/project/one/one-api-lab && php scripts/migrate_encrypt_patient.php" ``` - [ ] **Step 4: Verifikasi hasil migrasi** ```bash ssh devone "mysql -e 'SELECT COUNT(*) total, COUNT(M_PatientName_enc) sudah_enc FROM one_lab.m_patient;'" ``` Expected: `total` == `sudah_enc` (semua baris sudah terenkripsi). --- ## Task 5: Update `Patient.php` — `add_new()` dan `edit()` **Files:** - Modify: `application/controllers/mockup/fo/ibl_registration/Patient.php` - [ ] **Step 1: Load library di `__construct`** Di `__construct()` Patient.php, tambahkan setelah `parent::__construct()`: ```php $this->load->library('ibl_encryptor'); ``` - [ ] **Step 2: Update `add_new()` — enkripsi sebelum insert** Pada `add_new()`, ganti bagian pembentukan array `$ptn` dan `$add`: ```php // Enkripsi PII sebelum insert $dob_str = date('d-m-Y', strtotime($prm['M_PatientDOB'])); $ptn = [ 'M_PatientName' => $patient_name, 'M_PatientName_enc' => $this->ibl_encryptor->encrypt($patient_name), 'M_PatientName_bidx' => $this->ibl_encryptor->search_bidx($patient_name), 'M_PatientM_TitleID' => $prm['M_PatientM_TitleID'], 'M_PatientPrefix' => $prm['M_PatientPrefix'], 'M_PatientSuffix' => $prm['M_PatientSuffix'], 'M_PatientM_SexID' => $prm['M_PatientM_SexID'], 'M_PatientM_ReligionID' => $prm['M_PatientM_ReligionID'], 'M_PatientDOB' => $prm['M_PatientDOB'], 'M_PatientDOB_enc' => $this->ibl_encryptor->encrypt($dob_str), 'M_PatientDOB_bidx' => $this->ibl_encryptor->search_bidx($dob_str), 'M_PatientPOB' => $prm['M_PatientPOB'], 'M_PatientPOB_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientPOB']), 'M_PatientHP' => $prm['M_PatientHP'], 'M_PatientHP_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientHP']), 'M_PatientHP_bidx' => $this->ibl_encryptor->search_bidx($prm['M_PatientHP']), 'M_PatientPhone' => $prm['M_PatientPhone'], 'M_PatientPhone_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientPhone']), 'M_PatientEmail' => $prm['M_PatientEmail'], 'M_PatientEmail_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientEmail']), 'M_PatientM_IdTypeID' => $M_IdTypeID, 'M_PatientIDNumber' => $prm['M_PatientIDNumber'], 'M_PatientIDNumber_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientIDNumber']), 'M_PatientNote' => $prm['M_PatientNote'], 'M_PatientUserID' => $userid, 'M_PatientCreated' => date('Y-m-d H:i:s'), 'M_PatientCreatedUserID' => $userid ]; ``` Dan untuk `$add` (address): ```php $add = [ 'M_PatientAddressM_PatientID' => $id, 'M_PatientAddressDescription' => $address_description, 'M_PatientAddressDescription_enc' => $this->ibl_encryptor->encrypt($address_description), 'M_PatientAddressDescription_bidx' => $this->ibl_encryptor->search_bidx($address_description), 'M_PatientAddressUserID' => $userid, 'M_PatientAddressRegionalCd' => $prm['M_PatientAddressRegionalCd'], 'M_PatientAddressLocation' => $prm['M_PatientAddressLocation'], 'M_PatientAddressCity' => $prm['M_PatientAddressCity'], 'M_PatientAddressVillage' => $prm['M_PatientAddressVillage'], 'M_PatientAddressDistrict' => $prm['M_PatientAddressDistrict'], 'M_PatientAddressState' => $prm['M_PatientAddressState'], 'M_PatientAddressCountry' => $prm['M_PatientAddressCountry'], 'M_PatientAddressCountryCode' => $prm['M_PatientAddressCountryCode'], 'M_PatientAddressNote' => isset($prm['M_PatientAddressNote']) ? $prm['M_PatientAddressNote'] : 'Utama', 'M_PatientAddressCreated' => date('Y-m-d H:i:s'), 'M_PatientAddressCreatedUserID' => $userid ]; ``` - [ ] **Step 3: Update `edit()` — enkripsi sebelum update** Ganti chain `->set(...)` pada `edit()` untuk patient: ```php $dob_str = date('d-m-Y', strtotime($prm['M_PatientDOB'])); $this->db_smartone ->set('M_PatientName', $patient_name) ->set('M_PatientName_enc', $this->ibl_encryptor->encrypt($patient_name)) ->set('M_PatientName_bidx', $this->ibl_encryptor->search_bidx($patient_name)) ->set('M_PatientM_TitleID', $prm['M_PatientM_TitleID']) ->set('M_PatientPrefix', $prm['M_PatientPrefix']) ->set('M_PatientSuffix', $prm['M_PatientSuffix']) ->set('M_PatientM_SexID', $prm['M_PatientM_SexID']) ->set('M_PatientM_ReligionID', $prm['M_PatientM_ReligionID']) ->set('M_PatientDOB', $prm['M_PatientDOB']) ->set('M_PatientDOB_enc', $this->ibl_encryptor->encrypt($dob_str)) ->set('M_PatientDOB_bidx', $this->ibl_encryptor->search_bidx($dob_str)) ->set('M_PatientPOB', $prm['M_PatientPOB']) ->set('M_PatientPOB_enc', $this->ibl_encryptor->encrypt($prm['M_PatientPOB'])) ->set('M_PatientHP', $prm['M_PatientHP']) ->set('M_PatientHP_enc', $this->ibl_encryptor->encrypt($prm['M_PatientHP'])) ->set('M_PatientHP_bidx', $this->ibl_encryptor->search_bidx($prm['M_PatientHP'])) ->set('M_PatientPhone', $prm['M_PatientPhone']) ->set('M_PatientPhone_enc', $this->ibl_encryptor->encrypt($prm['M_PatientPhone'])) ->set('M_PatientEmail', $prm['M_PatientEmail']) ->set('M_PatientEmail_enc', $this->ibl_encryptor->encrypt($prm['M_PatientEmail'])) ->set('M_PatientM_IdTypeID', $prm['M_PatientM_IdTypeID']) ->set('M_PatientIDNumber', $prm['M_PatientIDNumber']) ->set('M_PatientIDNumber_enc', $this->ibl_encryptor->encrypt($prm['M_PatientIDNumber'])) ->set('M_PatientNote', $prm['M_PatientNote']) ->set('M_PatientUserID', $userid) ->set('M_PatientLastUpdatedUserID', $userid) ->where('M_PatientID', $prm['id']) ->update('m_patient'); ``` Dan untuk address di `edit()`: ```php $this->db_smartone ->set('M_PatientAddressRegionalCd', $prm['M_PatientAddressRegionalCd']) ->set('M_PatientAddressLocation', $prm['M_PatientAddressLocation']) ->set('M_PatientAddressCity', $prm['M_PatientAddressCity']) ->set('M_PatientAddressVillage', $prm['M_PatientAddressVillage']) ->set('M_PatientAddressDistrict', $prm['M_PatientAddressDistrict']) ->set('M_PatientAddressState', $prm['M_PatientAddressState']) ->set('M_PatientAddressCountry', $prm['M_PatientAddressCountry']) ->set('M_PatientAddressCountryCode', $prm['M_PatientAddressCountryCode']) ->set('M_PatientAddressDescription', $address_description) ->set('M_PatientAddressDescription_enc', $this->ibl_encryptor->encrypt($address_description)) ->set('M_PatientAddressDescription_bidx', $this->ibl_encryptor->search_bidx($address_description)) ->set('M_PatientAddressUserID', $userid) ->set('M_PatientAddressLastUpdatedUserID', $userid) ->where('M_PatientAddressID', $id_address) ->update('m_patientaddress'); ``` - [ ] **Step 4: Commit** ```bash git add application/controllers/mockup/fo/ibl_registration/Patient.php git commit -m "TASKCODE - Patient add_new/edit: enkripsi PII sebelum save ke DB" ``` --- ## Task 6: Update `Patient.php` — `search()` Pakai Blind Index **Files:** - Modify: `application/controllers/mockup/fo/ibl_registration/Patient.php` (fungsi `search()`) Strategi: untuk setiap parameter search yang sebelumnya pakai `LIKE`, ganti dengan subquery ke kolom `_bidx` menggunakan trigram token matching. Hasil tetap di-decrypt sebelum dikembalikan ke client. - [ ] **Step 1: Ganti logika `search()` untuk pakai bidx** Ganti seluruh blok `search()` (baris 75–188) dengan: ```php public function search() { $prm = $this->sys_input; $number_limit = 10; $number_offset = (!isset($prm['current_page']) ? 1 : $prm['current_page'] - 1) * $number_limit; // WHERE clauses $where_patient = "WHERE M_PatientIsActive = 'Y'"; $where_address = "AND M_PatientAddressIsActive = 'Y'"; $join_address = "join m_patientaddress on M_PatientAddressM_PatientID = M_PatientID {$where_address}"; $where_noreg = ''; $where_name = ''; $where_hp = ''; $where_dob = ''; $where_addr = ''; if (!empty($prm['noreg'])) { $noreg = $this->db_smartone->escape_like_str($prm['noreg']); $where_noreg = "AND M_PatientNoReg LIKE '%{$noreg}%'"; } if (!empty($prm['search'])) { $e = explode('+', $prm['search']); // Search name via trigram bidx if (!empty($e[0])) { $name_tokens = $this->ibl_encryptor->query_tokens($e[0]); if (!empty($name_tokens)) { $token_conditions = []; foreach ($name_tokens as $tok) { $tok_esc = $this->db_smartone->escape_str($tok); $token_conditions[] = "JSON_CONTAINS(M_PatientName_bidx, '\"$tok_esc\"')"; } $where_name = 'AND (' . implode(' AND ', $token_conditions) . ')'; } } // Search HP via trigram bidx if (!empty($e[1])) { $hp_tokens = $this->ibl_encryptor->query_tokens($e[1]); if (!empty($hp_tokens)) { $token_conditions = []; foreach ($hp_tokens as $tok) { $tok_esc = $this->db_smartone->escape_str($tok); $token_conditions[] = "JSON_CONTAINS(M_PatientHP_bidx, '\"$tok_esc\"')"; } $where_hp = 'AND (' . implode(' AND ', $token_conditions) . ')'; } } // Search DOB via trigram bidx if (!empty($e[2])) { $dob_tokens = $this->ibl_encryptor->query_tokens($e[2]); if (!empty($dob_tokens)) { $token_conditions = []; foreach ($dob_tokens as $tok) { $tok_esc = $this->db_smartone->escape_str($tok); $token_conditions[] = "JSON_CONTAINS(M_PatientDOB_bidx, '\"$tok_esc\"')"; } $where_dob = 'AND (' . implode(' AND ', $token_conditions) . ')'; } } // Search address via trigram bidx if (!empty($e[3])) { $addr_tokens = $this->ibl_encryptor->query_tokens($e[3]); if (!empty($addr_tokens)) { $token_conditions = []; foreach ($addr_tokens as $tok) { $tok_esc = $this->db_smartone->escape_str($tok); $token_conditions[] = "JSON_CONTAINS(M_PatientAddressDescription_bidx, '\"$tok_esc\"')"; } $where_addr = 'AND (' . implode(' AND ', $token_conditions) . ')'; } } } if (empty($where_addr)) { $join_address .= " AND M_PatientAddressNote = 'Utama'"; } $sql = "SELECT 'N' divider, M_PatientID, M_PatientNoReg, M_PatientEmail_enc, M_PatientPrefix, M_PatientSuffix, concat(M_TitleName,' ',IFNULL(M_PatientPrefix,''),' ',M_PatientName,' ',IFNULL(M_PatientSuffix,'')) M_PatientName_raw, M_PatientName_enc, M_PatientName M_PatientRealName, M_TitleID, M_TitleName, M_SexID, M_SexName, M_PatientHP_enc, M_PatientPOB_enc, M_PatientDOB_enc, M_PatientDOB, '' M_PatientAddress, M_PatientAddressID, M_PatientAddressDescription_enc, M_PatientM_IdTypeID, M_PatientIDNumber_enc, M_PatientAddressRegionalCd, M_PatientAddressLocation, M_PatientAddressCity, M_PatientAddressVillage, M_PatientAddressDistrict, M_PatientAddressState, M_PatientAddressCountry, M_PatientAddressCountryCode, IFNULL(M_PatientNote, '') M_PatientNote, M_PatientPhoto, M_PatientPhone_enc hp_phone, M_PatientAddressM_KelurahanID M_KelurahanID, 0 M_DistrictID, 0 M_CityID, 0 M_ProvinceID, M_PatientM_ReligionID, IFNULL(M_ReligionName, '-') M_ReligionName, IFNULL(Patient_SignatureUrl, '') image_signature FROM m_patient join m_title on M_PatientM_TitleID = M_TitleID join m_sex on M_PatientM_SexID = M_SexID {$join_address} left join m_religion on M_PatientM_ReligionID = M_ReligionID left join patient_signature on Patient_SignatureM_PatientID = M_PatientID and Patient_SignatureIsActive = 'Y' {$where_patient} {$where_noreg} {$where_name} {$where_hp} {$where_dob} {$where_addr} group by M_PatientID limit {$number_limit} offset {$number_offset}"; $query = $this->db_smartone->query($sql); if (!$query) { $this->sys_error_db("patient search error", $this->db_smartone); return; } $rows = $query->result_array(); // Dekripsi hasil $enc = $this->ibl_encryptor; foreach ($rows as &$row) { $row['M_PatientName'] = $enc->decrypt($row['M_PatientName_enc']) ?? $row['M_PatientRealName']; $row['M_PatientHP'] = $enc->decrypt($row['M_PatientHP_enc']); $row['dob_ina'] = $enc->decrypt($row['M_PatientDOB_enc']) ?? date('d-m-Y', strtotime($row['M_PatientDOB'])); $row['M_PatientEmail'] = $enc->decrypt($row['M_PatientEmail_enc']); $row['M_PatientIDNumber'] = $enc->decrypt($row['M_PatientIDNumber_enc']); $row['M_PatientPOB'] = $enc->decrypt($row['M_PatientPOB_enc']); $row['M_PatientAddressDescription'] = $enc->decrypt($row['M_PatientAddressDescription_enc']); $row['hp'] = $enc->decrypt($row['M_PatientPhone_enc']) ?: $row['M_PatientHP']; // Bersihkan kolom _enc dari response foreach (array_keys($row) as $k) { if (substr($k, -4) === '_enc' || substr($k, -5) === '_bidx') unset($row[$k]); } unset($row['M_PatientRealName'], $row['M_PatientName_raw'], $row['hp_phone']); } unset($row); $this->sys_ok($rows); } ``` - [ ] **Step 2: Commit** ```bash git add application/controllers/mockup/fo/ibl_registration/Patient.php git commit -m "TASKCODE - Patient search: ganti LIKE ke trigram blind index, decrypt hasil" ``` --- ## Task 7: Upload & Smoke Test di devone - [ ] **Step 1: Upload ke server** ```bash bash scripts/upload_ibl_committed_files.sh ``` - [ ] **Step 2: Pastikan .env ada di server** ```bash ssh devone "ls -la /home/one/project/one/one-api-lab/.env" ``` Jika belum ada, buat manual di server (isi dengan key yang sama dengan lokal): ```bash ssh devone "cat > /home/one/project/one/one-api-lab/.env << 'EOF' IBL_ENCRYPT_KEY= IBL_ENCRYPT_SEARCH_KEY= EOF" ``` - [ ] **Step 3: Jalankan migration di server** ```bash ssh devone "cd /home/one/project/one/one-api-lab && php scripts/migrate_encrypt_patient.php" ``` - [ ] **Step 4: Smoke test search endpoint** Test dengan nama yang ada di DB (ganti "BUDI" dengan nama pasien yang ada): ```bash curl -s -X POST https:///mockup/fo/ibl_registration/patient/search \ -H "Content-Type: application/json" \ -d '{"token":"","search":"BUDI","noreg":"","current_page":1}' | python3 -m json.tool | head -50 ``` Expected: response `{"status":"OK","data":[...]}` dengan nama pasien terdekripsi, bukan ciphertext. - [ ] **Step 5: Test add_new + search** Tambah pasien baru via API, kemudian search namanya. Pastikan hasil search menampilkan data yang benar. - [ ] **Step 6: Verifikasi data terenkripsi di DB** ```bash ssh devone "mysql -e 'SELECT M_PatientName, M_PatientName_enc FROM one_lab.m_patient LIMIT 3;'" ``` Expected: `M_PatientName` masih plaintext (untuk backward compat), `M_PatientName_enc` berisi base64 ciphertext. --- --- ## Task 8: SQL Migration — Tambah Kolom `_enc` untuk Hasil Lab **Files:** - Modify: `sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql` (append) - [ ] **Step 1: Append SQL untuk tabel hasil lab** ```sql -- t_orderdetail: nilai hasil lab utama ALTER TABLE one_lab.t_orderdetail ADD COLUMN T_OrderDetailResult_enc TEXT NULL AFTER T_OrderDetailResult, ADD COLUMN T_OrderDetailNote_enc TEXT NULL AFTER T_OrderDetailNote; -- t_orderheader: diagnosa dokter ALTER TABLE one_lab.t_orderheader ADD COLUMN T_OrderHeaderDiagnose_enc TEXT NULL AFTER T_OrderHeaderDiagnose; -- so_resultentrydetail: hasil lab standar ALTER TABLE one_lab.so_resultentrydetail ADD COLUMN So_ResultEntryDetailResult_enc TEXT NULL AFTER So_ResultEntryDetailResult; -- so_resultentrydetail_other: hasil lab nonstandar ALTER TABLE one_lab.so_resultentrydetail_other ADD COLUMN So_ResultEntryDetailOtherResult_enc TEXT NULL AFTER So_ResultEntryDetailOtherResult, ADD COLUMN So_ResultEntryDetailOtherResultBefore_enc TEXT NULL AFTER So_ResultEntryDetailOtherResultBefore; -- so_resultentry_fisik_umum: JSON pemeriksaan fisik ALTER TABLE one_lab.so_resultentry_fisik_umum ADD COLUMN So_ResultEntryFisikUmumDetails_enc TEXT NULL AFTER So_ResultEntryFisikUmumDetails; -- so_resultentry_fisik_summary: ringkasan fisik ALTER TABLE one_lab.so_resultentry_fisik_summary ADD COLUMN So_ResultEntryFisikSummaryValue_enc TEXT NULL AFTER So_ResultEntryFisikSummaryValue, ADD COLUMN So_ResultEntryFisikSummaryValue2_enc TEXT NULL AFTER So_ResultEntryFisikSummaryValue2; -- so_resultentry_other: catatan hasil ALTER TABLE one_lab.so_resultentry_other ADD COLUMN So_ResultEntryOtherNote_enc TEXT NULL AFTER So_ResultEntryOtherNote; -- so_resultentry_fisioterapi: detail fisioterapi ALTER TABLE one_lab.so_resultentry_fisioterapi ADD COLUMN So_ResultEntdyFisioterapiDetails_enc TEXT NULL AFTER So_ResultEntdyFisioterapiDetails; -- so_resultentry_smwt: hasil 6MWT (numerik → enkripsi sebagai teks) ALTER TABLE one_lab.so_resultentry_smwt ADD COLUMN So_ResultentrySmwtWeight_enc TEXT NULL AFTER So_ResultentrySmwtWeight, ADD COLUMN So_ResultentrySmwtHeight_enc TEXT NULL AFTER So_ResultentrySmwtHeight, ADD COLUMN So_ResultentrySmwtBMI_enc TEXT NULL AFTER So_ResultentrySmwtBMI, ADD COLUMN So_ResultentrySmwtPreTensi_enc TEXT NULL AFTER So_ResultentrySmwtPreTensi, ADD COLUMN So_ResultentrySmwtPreSPO2_enc TEXT NULL AFTER So_ResultentrySmwtPreSPO2, ADD COLUMN So_ResultentrySmwtPreNadi_enc TEXT NULL AFTER So_ResultentrySmwtPreNadi, ADD COLUMN So_ResultentrySmwtPostTensi_enc TEXT NULL AFTER So_ResultentrySmwtPostTensi, ADD COLUMN So_ResultentrySmwtPostSPO2_enc TEXT NULL AFTER So_ResultentrySmwtPostSPO2, ADD COLUMN So_ResultentrySmwtPostNadi_enc TEXT NULL AFTER So_ResultentrySmwtPostNadi, ADD COLUMN So_ResultentrySmwtVOMax_enc TEXT NULL AFTER So_ResultentrySmwtVOMax, ADD COLUMN So_ResultentrySmwtKategoriKebugaran_enc TEXT NULL AFTER So_ResultentrySmwtKategoriKebugaran; -- so_resultentry_srq29_conclusion: hasil SRQ-29 (kesehatan jiwa) ALTER TABLE one_lab.so_resultentry_srq29_conclusion ADD COLUMN So_ResultentrySrq29ConclusionResult_enc TEXT NULL AFTER So_ResultentrySrq29ConclusionResult; -- so_resultentrysdsinterpretation: interpretasi SDS ALTER TABLE one_lab.so_resultentrysdsinterpretation ADD COLUMN So_ResultEntrySDSInterpretationDisplay_enc TEXT NULL AFTER So_ResultEntrySDSInterpretationDisplay; -- member_eligible: data BPJS/asuransi ALTER TABLE one_lab.member_eligible ADD COLUMN Member_EligibleDescription_enc TEXT NULL AFTER Member_EligibleDescription; ``` - [ ] **Step 2: Jalankan di devone** ```bash ssh devone "mysql one_lab" < sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql ``` Verifikasi salah satu: ```bash ssh devone "mysql -e 'SHOW COLUMNS FROM one_lab.t_orderdetail LIKE \"%_enc\";'" ``` Expected: 2 baris (`T_OrderDetailResult_enc`, `T_OrderDetailNote_enc`). - [ ] **Step 3: Commit** ```bash git add sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql git commit -m "TASKCODE - SQL: tambah kolom _enc untuk tabel hasil lab" ``` --- ## Task 9: Migration Script — Enkripsi Data Hasil Lab **Files:** - Create: `scripts/migrate_encrypt_results.php` - [ ] **Step 1: Buat script** ```php PDO::ERRMODE_EXCEPTION] ); function migrate_simple($pdo, $enc, $table, $pk, $fields, $check_field) { $total = 0; $cols_select = implode(', ', array_merge([$pk], $fields)); $sets = implode(', ', array_map(fn($f) => "{$f}_enc = ?", $fields)); while (true) { $rows = $pdo->query("SELECT {$cols_select} FROM {$table} WHERE {$check_field}_enc IS NULL LIMIT 500")->fetchAll(PDO::FETCH_ASSOC); if (empty($rows)) break; $stmt = $pdo->prepare("UPDATE {$table} SET {$sets} WHERE {$pk} = ?"); foreach ($rows as $row) { $params = array_map(fn($f) => $enc->encrypt((string)($row[$f] ?? '')), $fields); $params[] = $row[$pk]; $stmt->execute($params); $total++; } echo " {$table}: {$total} rows...\n"; } echo "{$table}: selesai {$total} rows\n"; } // t_orderdetail migrate_simple($pdo, $enc, 't_orderdetail', 'T_OrderDetailID', ['T_OrderDetailResult', 'T_OrderDetailNote'], 'T_OrderDetailResult'); // t_orderheader migrate_simple($pdo, $enc, 't_orderheader', 'T_OrderHeaderID', ['T_OrderHeaderDiagnose'], 'T_OrderHeaderDiagnose'); // so_resultentrydetail migrate_simple($pdo, $enc, 'so_resultentrydetail', 'So_ResultEntryDetailID', ['So_ResultEntryDetailResult'], 'So_ResultEntryDetailResult'); // so_resultentrydetail_other migrate_simple($pdo, $enc, 'so_resultentrydetail_other', 'So_ResultEntryDetailOtherID', ['So_ResultEntryDetailOtherResult', 'So_ResultEntryDetailOtherResultBefore'], 'So_ResultEntryDetailOtherResult'); // so_resultentry_fisik_umum migrate_simple($pdo, $enc, 'so_resultentry_fisik_umum', 'So_ResultEntryFisikUmumID', ['So_ResultEntryFisikUmumDetails'], 'So_ResultEntryFisikUmumDetails'); // so_resultentry_fisik_summary migrate_simple($pdo, $enc, 'so_resultentry_fisik_summary', 'So_ResultEntryFisikSummaryID', ['So_ResultEntryFisikSummaryValue', 'So_ResultEntryFisikSummaryValue2'], 'So_ResultEntryFisikSummaryValue'); // so_resultentry_other migrate_simple($pdo, $enc, 'so_resultentry_other', 'So_ResultEntryOtherID', ['So_ResultEntryOtherNote'], 'So_ResultEntryOtherNote'); // so_resultentry_fisioterapi migrate_simple($pdo, $enc, 'so_resultentry_fisioterapi', 'So_ResultEntdyFisioterapiID', ['So_ResultEntdyFisioterapiDetails'], 'So_ResultEntdyFisioterapiDetails'); // so_resultentry_smwt migrate_simple($pdo, $enc, 'so_resultentry_smwt', 'So_ResultentrySmwtID', [ 'So_ResultentrySmwtWeight', 'So_ResultentrySmwtHeight', 'So_ResultentrySmwtBMI', 'So_ResultentrySmwtPreTensi', 'So_ResultentrySmwtPreSPO2', 'So_ResultentrySmwtPreNadi', 'So_ResultentrySmwtPostTensi', 'So_ResultentrySmwtPostSPO2', 'So_ResultentrySmwtPostNadi', 'So_ResultentrySmwtVOMax', 'So_ResultentrySmwtKategoriKebugaran' ], 'So_ResultentrySmwtWeight'); // so_resultentry_srq29_conclusion migrate_simple($pdo, $enc, 'so_resultentry_srq29_conclusion', 'So_ResultentrySrq29ConclusionID', ['So_ResultentrySrq29ConclusionResult'], 'So_ResultentrySrq29ConclusionResult'); // so_resultentrysdsinterpretation migrate_simple($pdo, $enc, 'so_resultentrysdsinterpretation', 'So_ResultEntrySDSInterpretationID', ['So_ResultEntrySDSInterpretationDisplay'], 'So_ResultEntrySDSInterpretationDisplay'); // member_eligible migrate_simple($pdo, $enc, 'member_eligible', 'Member_EligibleID', ['Member_EligibleDescription'], 'Member_EligibleDescription'); echo "=== Migrasi hasil lab selesai ===\n"; ``` - [ ] **Step 2: Commit** ```bash git add scripts/migrate_encrypt_results.php git commit -m "TASKCODE - migration script enkripsi batch data hasil lab" ``` - [ ] **Step 3: Upload dan jalankan di server** ```bash bash scripts/upload_ibl_committed_files.sh ssh devone "cd /home/one/project/one/one-api-lab && php scripts/migrate_encrypt_results.php" ``` - [ ] **Step 4: Verifikasi** ```bash ssh devone "mysql -e 'SELECT COUNT(*), COUNT(T_OrderDetailResult_enc) FROM one_lab.t_orderdetail;'" ``` Expected: kedua angka sama (semua row terenkripsi). --- ## Catatan Penting - **Kolom lama tidak dihapus** di sprint ini. Penghapusan kolom plaintext dilakukan di sprint terpisah setelah semua consumer API sudah update. - **Key rotation**: Jika key perlu diganti, seluruh data harus di-re-enkripsi via migration script. - **Masking** (tampilkan `081****5678`): ditambahkan di sprint berikutnya sebagai layer presentation di atas decrypt. - **`one_lab_log` log tables**: `log_patient` (377 rows), `log_fo` (86 rows), `log_resultentry` (0 rows) — termasuk dalam Task 8 SQL dan Task 9 migration. - **Key management**: Key WAJIB dibackup ke password manager DAN file lokal terenkripsi sebelum dijalankan ke production. Key hilang = data tidak bisa didekripsi.