feat: 지정판매소 개인정보(PII) 보호 — 마스킹·암호화·열람 게이트 (기본 OFF)
종량제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>
This commit is contained in:
86
app/Commands/ProtectDesignatedShops.php
Normal file
86
app/Commands/ProtectDesignatedShops.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user