종량제3 세션 작업. 마스터 플래그(Config\Pii.designatedShopProtection)가 OFF면 현재 동작(원문 그대로) 유지, ON일 때만 마스킹·게이트 적용. - 표시 계층 단일 지점 마스킹: 대표자명/전화/이메일/계좌/가상계좌 (목록·상세·엑셀·판매대장·전화주문) - 원문 열람 게이트: 지자체 허용 IP 자동열람 or 2단계 인증(TOTP) step-up + 감사기록(revealPii) - 정확일치 검색: 블라인드 인덱스(HMAC) 컬럼 있으면 사용, 없으면 평문 폴백 - 저장계층 암호화(encryptAtRest, 기본 OFF): 모델 콜백 + spark 명령 pii:protect-designated-shops - SQL(추가·멱등): designated_shop_add_pii_bidx / local_government_add_allow_ips - Auth: TOTP 실제 통과 시에만 세션 auth_2fa_verified=true Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
3.5 KiB
PHP
87 lines
3.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Commands;
|
|
|
|
use CodeIgniter\CLI\BaseCommand;
|
|
use CodeIgniter\CLI\CLI;
|
|
use Config\Database;
|
|
|
|
/**
|
|
* 지정판매소 PII 일괄 암호화 + 블라인드 인덱스 백필 (설계안 P3·P4).
|
|
*
|
|
* ⚠️ 운영 데이터를 변경하는 비가역적 작업. 반드시 DB 백업 후 실행.
|
|
* php spark pii:protect-designated-shops --dry (미리보기: 변경 안 함)
|
|
* php spark pii:protect-designated-shops --force (실제 실행)
|
|
*
|
|
* - 멱등: 이미 ENC: 인 필드는 건너뜀. 두 번 돌려도 안전.
|
|
* - 앱 encrypter(.env encryption.key) 사용 → 반드시 키가 설정돼 있어야 함.
|
|
*/
|
|
class ProtectDesignatedShops extends BaseCommand
|
|
{
|
|
protected $group = 'PII';
|
|
protected $name = 'pii:protect-designated-shops';
|
|
protected $description = '지정판매소 PII를 암호화하고 블라인드 인덱스를 백필합니다(비가역적, 백업 필수).';
|
|
protected $usage = 'pii:protect-designated-shops [--dry|--force]';
|
|
|
|
public function run(array $params)
|
|
{
|
|
helper('pii_encryption');
|
|
$dry = array_key_exists('dry', $params) || in_array('--dry', $_SERVER['argv'] ?? [], true);
|
|
$force = array_key_exists('force', $params) || in_array('--force', $_SERVER['argv'] ?? [], true);
|
|
|
|
if (! $dry && ! $force) {
|
|
CLI::error('안전을 위해 --dry(미리보기) 또는 --force(실행) 중 하나가 필요합니다.');
|
|
return;
|
|
}
|
|
|
|
$cfg = config('Encryption');
|
|
if ((string) $cfg->key === '') {
|
|
CLI::error('encryption.key 가 비어 있습니다. .env 설정 후 실행하세요. (키 없이 암호화 불가)');
|
|
return;
|
|
}
|
|
|
|
$db = Database::connect();
|
|
$piiFields = ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
|
$hasTelBidx = $db->fieldExists('ds_tel_bidx', 'designated_shop');
|
|
$hasNameBidx = $db->fieldExists('ds_rep_name_bidx', 'designated_shop');
|
|
|
|
$rows = $db->table('designated_shop')->select('ds_idx, ' . implode(', ', $piiFields))->get()->getResultArray();
|
|
CLI::write('대상 행: ' . count($rows) . ($dry ? ' [DRY-RUN: 변경 없음]' : ' [실제 실행]'), $dry ? 'yellow' : 'green');
|
|
|
|
$encCount = 0; $rowCount = 0;
|
|
foreach ($rows as $r) {
|
|
$update = [];
|
|
foreach ($piiFields as $f) {
|
|
$v = (string) ($r[$f] ?? '');
|
|
if ($v !== '' && strpos($v, 'ENC:') !== 0) {
|
|
$update[$f] = pii_encrypt($v);
|
|
$encCount++;
|
|
}
|
|
}
|
|
// 블라인드 인덱스(원본값 기준)
|
|
if ($hasTelBidx) {
|
|
$update['ds_tel_bidx'] = pii_blind_index((string) ($r['ds_tel'] ?? ''));
|
|
}
|
|
if ($hasNameBidx) {
|
|
$update['ds_rep_name_bidx'] = pii_blind_index((string) ($r['ds_rep_name'] ?? ''));
|
|
}
|
|
if ($update === []) {
|
|
continue;
|
|
}
|
|
$rowCount++;
|
|
if (! $dry) {
|
|
$db->table('designated_shop')->where('ds_idx', (int) $r['ds_idx'])->update($update);
|
|
}
|
|
}
|
|
|
|
CLI::write('변경 대상 행: ' . $rowCount . ' / 암호화 필드: ' . $encCount, 'green');
|
|
if ($dry) {
|
|
CLI::write('DRY-RUN 이었습니다. 실제 반영하려면 --force 로 다시 실행하세요.', 'yellow');
|
|
} else {
|
|
CLI::write('완료. (멱등: 재실행해도 이미 암호화된 값은 건너뜁니다)', 'green');
|
|
}
|
|
}
|
|
}
|