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:
@@ -1,5 +1,9 @@
|
||||
<?php
|
||||
helper('admin');
|
||||
helper(['admin', 'pii_encryption']);
|
||||
$repDisp = static function ($row): string {
|
||||
$v = (string) ($row->ds_rep_name ?? '');
|
||||
return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v);
|
||||
};
|
||||
$currentPath = current_nav_request_path();
|
||||
if ($currentPath === 'bag/designated-shops') {
|
||||
$readOnly = false;
|
||||
@@ -213,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
||||
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label class="text-sm text-gray-600">대표자명</label>
|
||||
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||
<label class="text-sm text-gray-600">전화</label>
|
||||
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||
<label class="text-sm text-gray-600">상태</label>
|
||||
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
<option value="">전체</option>
|
||||
@@ -282,14 +290,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
|
||||
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
|
||||
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
|
||||
data-sort-rep="<?= esc($row->ds_rep_name ?? '', 'attr') ?>"
|
||||
data-sort-rep="<?= esc($repDisp($row), 'attr') ?>"
|
||||
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
|
||||
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
|
||||
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
|
||||
data-sort-zone="<?= esc($zone, 'attr') ?>"
|
||||
data-sort-state="<?= (int) $st ?>">
|
||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></td>
|
||||
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
|
||||
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
|
||||
@@ -312,6 +320,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
||||
<div class="ds-detail-inner" id="ds-detail-box">
|
||||
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
|
||||
|
||||
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
|
||||
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
|
||||
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
|
||||
</div>
|
||||
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
|
||||
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||
<div class="flex gap-1">
|
||||
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
|
||||
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
|
||||
</div>
|
||||
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ds-detail-fields" class="hidden">
|
||||
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
|
||||
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
||||
@@ -478,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
fieldsWrap.classList.remove('hidden');
|
||||
fillDetailInfoTable(d);
|
||||
|
||||
// 마스킹된 행이면 "원문 보기" 안내 노출
|
||||
var revealBox = document.getElementById('ds-pii-reveal');
|
||||
if (revealBox) {
|
||||
revealBox.classList.toggle('hidden', !d.pii_masked);
|
||||
var rf = document.getElementById('ds-pii-reveal-form');
|
||||
if (rf) rf.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (!readOnly && editLink && delForm && delBtn) {
|
||||
var id = d.ds_idx;
|
||||
editLink.href = editBase + '/' + id;
|
||||
@@ -569,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
delBtn.disabled = true;
|
||||
delBtn.classList.add('pointer-events-none', 'opacity-40');
|
||||
}
|
||||
|
||||
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
|
||||
(function () {
|
||||
var box = document.getElementById('ds-pii-reveal');
|
||||
if (!box) return;
|
||||
var btn = document.getElementById('ds-pii-reveal-btn');
|
||||
var form = document.getElementById('ds-pii-reveal-form');
|
||||
var otp = document.getElementById('ds-pii-otp');
|
||||
var reason = document.getElementById('ds-pii-reason');
|
||||
var submit = document.getElementById('ds-pii-reveal-submit');
|
||||
var cancel = document.getElementById('ds-pii-reveal-cancel');
|
||||
var msg = document.getElementById('ds-pii-reveal-msg');
|
||||
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
|
||||
var csrfName = '<?= csrf_token() ?>';
|
||||
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
|
||||
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
|
||||
submit.addEventListener('click', function () {
|
||||
msg.textContent = '';
|
||||
var body = new URLSearchParams();
|
||||
body.set('totp_code', (otp.value || '').trim());
|
||||
body.set('reason', (reason.value || '').trim());
|
||||
var t = document.querySelector('meta[name="' + csrfName + '"]');
|
||||
body.set(csrfName, '<?= csrf_hash() ?>');
|
||||
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.ok) { location.reload(); }
|
||||
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
|
||||
})
|
||||
.catch(function () { msg.textContent = '통신 오류'; });
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -630,7 +696,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<td class="text-left"><?= esc($dongDispP) ?></td>
|
||||
<td class="text-center"><?= esc($daDispP) ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($repDisp($row)) ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($zipP) ?></td>
|
||||
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
||||
|
||||
Reference in New Issue
Block a user