종량제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>
159 lines
5.4 KiB
PHP
159 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use CodeIgniter\Model;
|
|
|
|
class DesignatedShopModel extends Model
|
|
{
|
|
use \App\Models\Traits\Auditable;
|
|
|
|
protected $table = 'designated_shop';
|
|
protected $primaryKey = 'ds_idx';
|
|
protected $returnType = 'object';
|
|
protected $useTimestamps = false;
|
|
protected $allowedFields = [
|
|
'ds_lg_idx',
|
|
'ds_sa_idx',
|
|
'ds_mb_idx',
|
|
'ds_shop_no',
|
|
'ds_name',
|
|
'ds_biz_no',
|
|
'ds_rep_name',
|
|
'ds_biz_type',
|
|
'ds_biz_kind',
|
|
'ds_va_number',
|
|
'ds_va_bank',
|
|
'ds_va_account',
|
|
'ds_zip',
|
|
'ds_addr',
|
|
'ds_addr_jibun',
|
|
'ds_addr_detail',
|
|
'ds_tel',
|
|
'ds_rep_phone',
|
|
'ds_email',
|
|
'ds_gugun_code',
|
|
'ds_dong_code',
|
|
'ds_zone_code',
|
|
'ds_branch_no',
|
|
'ds_designated_at',
|
|
'ds_state',
|
|
'ds_state_changed_at',
|
|
'ds_change_reason',
|
|
'ds_regdate',
|
|
// 블라인드 인덱스(정확일치 검색용). 컬럼이 있을 때만 실제로 기록됨.
|
|
'ds_tel_bidx',
|
|
'ds_rep_name_bidx',
|
|
];
|
|
|
|
// PII 암복호화·블라인드인덱스 콜백 (설계안 P3·P4)
|
|
protected $beforeInsert = ['piiBlindIndex', 'piiEncrypt'];
|
|
protected $beforeUpdate = ['piiBlindIndex', 'piiEncrypt'];
|
|
protected $afterFind = ['piiDecrypt'];
|
|
|
|
private ?bool $telBidxExists = null;
|
|
private ?bool $nameBidxExists = null;
|
|
|
|
/** 저장 계층 암호화 사용 여부 (Config\Pii.encryptAtRest) */
|
|
private function encryptAtRest(): bool
|
|
{
|
|
try {
|
|
return (bool) (config('Pii')->encryptAtRest ?? false);
|
|
} catch (\Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** @return list<string> 암호화·마스킹 대상 PII 필드 */
|
|
private function piiFields(): array
|
|
{
|
|
try {
|
|
$f = config('Pii')->designatedShopPiiFields ?? [];
|
|
return is_array($f) && $f !== [] ? $f : ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
|
} catch (\Throwable $e) {
|
|
return ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
|
}
|
|
}
|
|
|
|
/** 저장 전 PII 필드 암호화 (encryptAtRest ON일 때만, 이미 ENC:면 건너뜀) */
|
|
protected function piiEncrypt(array $data): array
|
|
{
|
|
if (! $this->encryptAtRest() || empty($data['data']) || ! is_array($data['data'])) {
|
|
return $data;
|
|
}
|
|
helper('pii_encryption');
|
|
foreach ($this->piiFields() as $f) {
|
|
if (array_key_exists($f, $data['data']) && $data['data'][$f] !== null && $data['data'][$f] !== '') {
|
|
$v = (string) $data['data'][$f];
|
|
if (strpos($v, 'ENC:') !== 0) {
|
|
$data['data'][$f] = pii_encrypt($v);
|
|
}
|
|
}
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/** 저장 전 블라인드 인덱스(전화·이름) 기록 — 컬럼과 인덱스키가 있을 때만 */
|
|
protected function piiBlindIndex(array $data): array
|
|
{
|
|
if (empty($data['data']) || ! is_array($data['data'])) {
|
|
return $data;
|
|
}
|
|
helper('pii_encryption');
|
|
$db = $this->db;
|
|
if ($this->telBidxExists === null) {
|
|
$this->telBidxExists = $db->fieldExists('ds_tel_bidx', $this->table);
|
|
}
|
|
if ($this->nameBidxExists === null) {
|
|
$this->nameBidxExists = $db->fieldExists('ds_rep_name_bidx', $this->table);
|
|
}
|
|
// 원본값이 넘어온 경우에만 인덱스 갱신(암호화 콜백이 먼저 돌면 ENC:라 인덱스 불가 → piiBlindIndex를 piiEncrypt보다 먼저 두지 않음에 주의)
|
|
if ($this->telBidxExists && array_key_exists('ds_tel', $data['data'])) {
|
|
$raw = (string) $data['data']['ds_tel'];
|
|
$data['data']['ds_tel_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
|
|
}
|
|
if ($this->nameBidxExists && array_key_exists('ds_rep_name', $data['data'])) {
|
|
$raw = (string) $data['data']['ds_rep_name'];
|
|
$data['data']['ds_rep_name_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/** 조회 후 PII 필드 복호화 (ENC: 값만; 평문은 그대로) */
|
|
protected function piiDecrypt(array $data): array
|
|
{
|
|
if (empty($data['data'])) {
|
|
return $data;
|
|
}
|
|
helper('pii_encryption');
|
|
$fields = $this->piiFields();
|
|
$decodeOne = static function ($row) use ($fields) {
|
|
if (is_object($row)) {
|
|
foreach ($fields as $f) {
|
|
if (isset($row->$f) && is_string($row->$f) && strpos($row->$f, 'ENC:') === 0) {
|
|
$row->$f = pii_decrypt($row->$f);
|
|
}
|
|
}
|
|
} elseif (is_array($row)) {
|
|
foreach ($fields as $f) {
|
|
if (isset($row[$f]) && is_string($row[$f]) && strpos($row[$f], 'ENC:') === 0) {
|
|
$row[$f] = pii_decrypt($row[$f]);
|
|
}
|
|
}
|
|
}
|
|
return $row;
|
|
};
|
|
if (is_array($data['data'])) {
|
|
// findAll 등: 결과 배열
|
|
foreach ($data['data'] as $i => $row) {
|
|
$data['data'][$i] = $decodeOne($row);
|
|
}
|
|
} else {
|
|
// find/first: 단일
|
|
$data['data'] = $decodeOne($data['data']);
|
|
}
|
|
return $data;
|
|
}
|
|
}
|
|
|