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:
taekyoungc
2026-07-16 15:45:28 +09:00
parent c5df784f91
commit 5e8e81f404
12 changed files with 726 additions and 19 deletions

View File

@@ -174,7 +174,7 @@ class DesignatedShop extends BaseController
/**
* 목록 검색과 동일한 조건을 모델 쿼리에 적용한다.
*/
private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): void
private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): void
{
$model->where('ds_lg_idx', $lgIdx);
if ($dsName !== null && $dsName !== '') {
@@ -186,12 +186,47 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') {
$model->where('ds_state', (int) $dsState);
}
$this->applyPiiSearchFilter($model, $dsRep, $dsTel);
}
/**
* 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치,
* 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백.
*
* @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q
*/
private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void
{
$dsRep = $dsRep !== null ? trim($dsRep) : '';
$dsTel = $dsTel !== null ? trim($dsTel) : '';
if ($dsRep === '' && $dsTel === '') {
return;
}
helper('pii_encryption');
$db = \Config\Database::connect();
if ($dsRep !== '') {
$bidx = pii_blind_index($dsRep);
if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) {
$q->where('ds_rep_name_bidx', $bidx);
} else {
$q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치)
}
}
if ($dsTel !== '') {
$bidx = pii_blind_index($dsTel);
if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) {
$q->where('ds_tel_bidx', $bidx);
} else {
$q->where('ds_tel', $dsTel);
}
}
}
/**
* @return array{1: int, 2: int, 3: int, total: int}
*/
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array
{
$db = \Config\Database::connect();
$builder = $db->table('designated_shop');
@@ -205,6 +240,7 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') {
$builder->where('ds_state', (int) $dsState);
}
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
->groupBy('ds_state')
->get()
@@ -229,7 +265,7 @@ class DesignatedShop extends BaseController
*/
private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
{
helper('admin');
helper(['admin', 'pii_encryption']);
$lgIdx = admin_effective_lg_idx() ?? 0;
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
$payload = [];
@@ -258,7 +294,7 @@ class DesignatedShop extends BaseController
$dongDisplay = $gugunName;
}
$payload[] = [
$p = [
'ds_idx' => (int) $row->ds_idx,
'ds_shop_no' => $sn,
'shop_no_display' => $shortNo,
@@ -291,7 +327,18 @@ class DesignatedShop extends BaseController
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
'pii_masked' => false,
];
// 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹
if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) {
foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) {
$p[$f] = mask_shop_field($f, (string) $p[$f]);
}
$p['pii_masked'] = true;
}
$payload[] = $p;
}
return $payload;
@@ -311,11 +358,13 @@ class DesignatedShop extends BaseController
return null;
}
// 다조건 검색 (P2-15)
// 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
$dsName = $this->request->getGet('ds_name');
$dsGugunCode = $this->request->getGet('ds_gugun_code');
$dsState = $this->request->getGet('ds_state');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState);
$dsRep = $this->request->getGet('ds_rep');
$dsTel = $this->request->getGet('ds_tel');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
@@ -327,7 +376,7 @@ class DesignatedShop extends BaseController
$lgMap[$lg->lg_idx] = $lg->lg_name;
}
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState);
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
$gugunNameMap = $this->gugunCodeNameMap($lgIdx);
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
@@ -356,6 +405,8 @@ class DesignatedShop extends BaseController
'dsName' => $dsName ?? '',
'dsGugunCode' => $dsGugunCode ?? '',
'dsState' => $dsState ?? '',
'dsRep' => $dsRep ?? '',
'dsTel' => $dsTel ?? '',
'gugunCodes' => $gugunCodes,
'stateCounts' => $stateCounts,
'gugunNameMap' => $gugunNameMap,
@@ -508,6 +559,7 @@ class DesignatedShop extends BaseController
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
}
helper('pii_encryption');
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
// 동코드(구·군 동코드) 산출
@@ -522,19 +574,30 @@ class DesignatedShop extends BaseController
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
$da = $row->ds_designated_at ?? null;
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
// 엑셀에도 마스킹 적용(열람 권한 없으면)
$canView = can_view_shop_pii((int) ($row->ds_lg_idx ?? 0));
$repName = (string) ($row->ds_rep_name ?? '');
$telVal = (string) ($row->ds_tel ?? '');
$vaAccount = $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : (string) ($row->ds_va_number ?? '');
if (! $canView) {
$repName = mask_shop_field('ds_rep_name', $repName);
$telVal = mask_shop_field('ds_tel', $telVal);
$vaAccount = mask_shop_field('ds_va_account', $vaAccount);
}
$rows[] = [
$row->ds_idx,
$row->ds_shop_no,
$row->ds_name,
$row->ds_rep_name,
$repName,
$dongDisp,
$daDisp,
$row->ds_biz_no,
$this->designatedShopScalar($row, 'ds_biz_type'),
$this->designatedShopScalar($row, 'ds_biz_kind'),
$this->designatedShopScalar($row, 'ds_va_bank'),
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''),
$row->ds_tel ?? '',
$vaAccount,
$telVal,
$row->ds_addr ?? '',
$this->designatedShopScalar($row, 'ds_zone_code'),
$this->designatedShopScalar($row, 'ds_branch_no'),
@@ -1675,6 +1738,51 @@ class DesignatedShop extends BaseController
]);
}
/**
* [AJAX] 개인정보 원문 보기 — step-up 재인증(TOTP) + 열람사유 → 세션 열람 승인 + 감사기록.
* 조건 미충족(IP밖·Super Admin 등) 사용자가 마스킹된 PII를 열람할 때 사용.
*/
public function revealPii()
{
helper(['admin', 'pii_encryption']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return $this->response->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
}
$mbIdx = (int) session()->get('mb_idx');
$code = trim((string) $this->request->getPost('totp_code'));
$reason = trim((string) $this->request->getPost('reason'));
if ($code === '' || ! preg_match('/^\d{6}$/', $code)) {
return $this->response->setJSON(['ok' => false, 'error' => '6자리 인증코드를 입력해 주세요.']);
}
if ($reason === '') {
return $this->response->setJSON(['ok' => false, 'error' => '열람 사유를 입력해 주세요.']);
}
$member = model(\App\Models\MemberModel::class)->find($mbIdx);
$secret = $member ? pii_decrypt((string) ($member->mb_totp_secret ?? '')) : '';
if ($secret === '' || (int) ($member->mb_totp_enabled ?? 0) !== 1) {
return $this->response->setJSON(['ok' => false, 'error' => '2단계 인증(TOTP)이 등록돼 있지 않아 원문 열람을 승인할 수 없습니다.']);
}
$totp = new \App\Libraries\TotpService();
if (! $totp->verify($secret, $code)) {
return $this->response->setJSON(['ok' => false, 'error' => '인증코드가 올바르지 않습니다.']);
}
// 이 세션에서 해당 지자체 PII 원문 열람 승인(10분)
session()->set('pii_stepup_grant', ['lg_idx' => (int) $lgIdx, 'until' => time() + 600]);
// 감사기록 — 누가/언제/사유
helper('audit');
audit_log('pii_view', 'designated_shop', 0, null, [
'lg_idx' => (int) $lgIdx,
'reason' => mb_substr($reason, 0, 200),
'ip' => (string) $this->request->getIPAddress(),
]);
return $this->response->setJSON(['ok' => true]);
}
/**
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
*/

View File

@@ -118,6 +118,16 @@ class SalesReport extends BaseController
$hasBsFee
);
// 대표자명 PII: 암호화 대비 복호화 + 권한 없으면 마스킹 (표시 계층)
helper('pii_encryption');
$canViewPii = can_view_shop_pii((int) $lgIdx);
foreach ($detailRows as $row) {
if (isset($row->ds_rep_name) && $row->ds_rep_name !== '') {
$rep = pii_decrypt((string) $row->ds_rep_name);
$row->ds_rep_name = $canViewPii ? $rep : mask_shop_field('ds_rep_name', $rep);
}
}
$filtered = [];
foreach ($detailRows as $row) {
if ($this->ledgerRowMatchesCategories($row, $cats)) {