Improve data access control UX guidance

This commit is contained in:
devmrko
2026-07-13 23:14:51 +09:00
parent e18b30feab
commit a40102a79a
10 changed files with 343 additions and 5 deletions

View File

@@ -0,0 +1,141 @@
package com.cloudhandson.vpdbackoffice.domain.operation;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import java.util.List;
/**
* Page-level health summary computed from the same DB-backed rows shown in
* operation-status.html. It intentionally does not hide the row-level evidence:
* the summary is only a triage layer for operators.
*/
public record OperationHealthSummary(
int protectedObjectCount,
int rowPolicyOkCount,
int rowPolicyWarnCount,
int rowPolicyErrorCount,
int missingHandlerCount,
int missingPolicyCount,
int invalidFunctionCount,
int unverifiedCount,
int maskingPolicyCount,
int maskingAppliedCount,
int maskingInactiveExpectedCount,
int maskingMismatchCount
) {
public static OperationHealthSummary from(
List<OperationStatusRow> rows,
List<MaskingPolicyStatus> maskingStatuses
) {
int rowPolicyOk = 0;
int rowPolicyWarn = 0;
int rowPolicyError = 0;
int missingHandler = 0;
int missingPolicy = 0;
int invalidFunction = 0;
int unverified = 0;
for (OperationStatusRow row : rows) {
switch (row.healthLevel()) {
case "OK" -> rowPolicyOk++;
case "ERROR" -> rowPolicyError++;
default -> rowPolicyWarn++;
}
if (row.handlerId() == null) {
missingHandler++;
}
if (row.policyNames() == null || row.policyNames().isBlank()) {
missingPolicy++;
}
if (row.functionStatus() != null && !"VALID".equalsIgnoreCase(row.functionStatus())) {
invalidFunction++;
}
if (row.lastProbeStatus() == null || row.lastProbeStatus().isBlank()) {
unverified++;
}
}
int maskingApplied = 0;
int maskingInactive = 0;
int maskingMismatch = 0;
for (MaskingPolicyStatus status : maskingStatuses) {
if (status.applied()) {
maskingApplied++;
} else if (status.inactiveAsExpected()) {
maskingInactive++;
} else {
maskingMismatch++;
}
}
return new OperationHealthSummary(
rows.size(),
rowPolicyOk,
rowPolicyWarn,
rowPolicyError,
missingHandler,
missingPolicy,
invalidFunction,
unverified,
maskingStatuses.size(),
maskingApplied,
maskingInactive,
maskingMismatch
);
}
public String overallLevel() {
if (rowPolicyErrorCount > 0 || invalidFunctionCount > 0 || maskingMismatchCount > 0) {
return "ERROR";
}
if (rowPolicyWarnCount > 0 || missingHandlerCount > 0 || missingPolicyCount > 0
|| unverifiedCount > 0) {
return "WARN";
}
return "OK";
}
public String overallLabel() {
return switch (overallLevel()) {
case "ERROR" -> "조치 필요";
case "WARN" -> "확인 필요";
default -> "정상";
};
}
public String overallBadgeClass() {
return switch (overallLevel()) {
case "ERROR" -> "text-bg-danger";
case "WARN" -> "text-bg-warning";
default -> "text-bg-success";
};
}
public String rowPolicySummary() {
return "정상 " + rowPolicyOkCount + " · 확인 " + rowPolicyWarnCount + " · 오류 " + rowPolicyErrorCount;
}
public String maskingSummary() {
return "적용 " + maskingAppliedCount + " · 미적용 정상 " + maskingInactiveExpectedCount
+ " · 불일치 " + maskingMismatchCount;
}
public String primaryAction() {
if (invalidFunctionCount > 0) {
return "컴파일 오류가 있는 VPD Filter를 먼저 확인하세요.";
}
if (maskingMismatchCount > 0) {
return "컬럼 마스킹 화면에서 DB ASO 정책 동기화를 확인하세요.";
}
if (missingPolicyCount > 0) {
return "보호 상태에서 행 접근 정책(VPD)을 적용하세요.";
}
if (missingHandlerCount > 0) {
return "조회 대상 또는 조회 연동에서 ORDS handler를 확인하세요.";
}
if (unverifiedCount > 0) {
return "접근 검증을 실행해 실제 결과를 확인하세요.";
}
return "현재 요약 기준으로 즉시 조치할 항목은 없습니다.";
}
}

View File

@@ -1,5 +1,6 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.operation.OperationHealthSummary;
import com.cloudhandson.vpdbackoffice.service.MaskingRuleService;
import com.cloudhandson.vpdbackoffice.service.OperationStatusService;
import org.springframework.stereotype.Controller;
@@ -19,8 +20,11 @@ public class OperationStatusController {
@GetMapping("/operation-status")
public String status(Model model) {
model.addAttribute("rows", service.findRows());
model.addAttribute("maskingPolicyStatuses", maskingRuleService.findPolicyStatuses());
var rows = service.findRows();
var maskingPolicyStatuses = maskingRuleService.findPolicyStatuses();
model.addAttribute("rows", rows);
model.addAttribute("maskingPolicyStatuses", maskingPolicyStatuses);
model.addAttribute("summary", OperationHealthSummary.from(rows, maskingPolicyStatuses));
return "operation-status";
}
}

View File

@@ -2222,6 +2222,27 @@ body {
margin: .4rem 0 1rem;
}
.guided-check-list {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 10px;
color: var(--rw-muted);
display: grid;
gap: .4rem;
margin: .8rem 0 1rem;
padding: .85rem .95rem .85rem 1.8rem;
}
.guided-check-list li {
line-height: 1.45;
}
.guided-check-list a {
color: var(--rw-primary);
font-weight: 800;
text-decoration: none;
}
.dashboard-action-grid {
display: grid;
gap: .7rem;

View File

@@ -116,6 +116,36 @@
<div class="mt-2" th:if="${showSupportCommand}"><code>./run.sh backoffice-support</code></div>
</div>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">전체 그림</span>
<h2>토큰은 사용자를 식별하고, DB 정책이 행과 컬럼을 나눠서 제어합니다</h2>
<p class="section-subtitle">이 백오피스는 토큰 자체에 권한을 복사하지 않습니다. 요청 시점에 토큰으로 사용자를 찾고, 저장된 역할·규칙을 DB 세션 context와 VPD/ASO 정책에 반영합니다.</p>
</div>
</div>
<div class="effective-preview">
<dl>
<div>
<dt>1. 사용자 식별</dt>
<dd>Bearer Token으로 <code>CB_AGENT_CTX</code>에 사용자·부서·이해관계자 정보를 설정합니다.</dd>
</div>
<div>
<dt>2. 행 접근(VPD)</dt>
<dd>행 접근 규칙이 대상 테이블의 WHERE predicate로 변환되어 볼 수 있는 행만 남깁니다.</dd>
</div>
<div>
<dt>3. 컬럼 마스킹(ASO)</dt>
<dd>허용된 행 안에서 민감 컬럼을 원문으로 줄지, 마스킹해서 줄지 결정합니다.</dd>
</div>
<div>
<dt>4. 접근 검증</dt>
<dd>토큰으로 실제 ORDS 조회를 실행하고, 결과 행·마스킹 컬럼·감사 증적을 확인합니다.</dd>
</div>
</dl>
</div>
</section>
<section class="dashboard-menu-flow" aria-labelledby="menu-flow-title">
<div class="dashboard-section-heading">
<span class="architecture-kicker">업무 흐름</span>
@@ -132,6 +162,13 @@
<section class="dashboard-command-center" aria-label="주요 권한 작업">
<div class="dashboard-primary-action">
<h2>바로 시작</h2>
<ol class="guided-check-list">
<li><a href="/users">사용자</a><a href="/roles">역할</a>을 준비합니다.</li>
<li><a href="/permissions">행 접근 규칙</a>에서 역할별 대상 객체와 행 조건을 저장합니다.</li>
<li><a href="/vpd-policies">보호 상태</a>에서 대상 테이블의 VPD 연결 상태를 확인합니다.</li>
<li><a href="/masking-rules">컬럼 마스킹</a>에서 민감 컬럼의 ASO 정책을 연결합니다.</li>
<li><a href="/tokens">검증 세션</a>을 발급하고 <a href="/probe">접근 검증</a>에서 실제 결과를 확인합니다.</li>
</ol>
<div class="dashboard-action-grid">
<a class="dashboard-action" href="/permissions">
<span><strong>행 접근 규칙</strong><small>역할별 객체·행 조건을 설정합니다. 컬럼은 ASO 마스킹에서 관리합니다.</small></span>

View File

@@ -16,6 +16,35 @@
<p class="probe-summary" th:text="${result.plainSummary()}">결과 설명</p>
<section class="result-section">
<div class="section-heading compact-heading">
<h3>검증 흐름 요약</h3>
<span class="badge text-bg-light">토큰 → VPD → ASO → FGA</span>
</div>
<div class="effective-preview">
<dl>
<div>
<dt>1. 토큰 해석</dt>
<dd th:if="${tokenContext}" th:text="${tokenContext.username() + ' 사용자로 해석됐습니다.'}">사용자 확인</dd>
<dd th:unless="${tokenContext}">유효한 사용자로 해석되지 않았습니다. 이 경우 DB context가 설정되지 않아 권한이 없습니다.</dd>
</div>
<div>
<dt>2. 행 접근(VPD)</dt>
<dd th:text="${result.rowCount() == 0 ? 'VPD 적용 후 반환 가능한 행이 없습니다.' : 'VPD 적용 후 반환 가능한 행이 있습니다.'}">행 접근 결과</dd>
</div>
<div>
<dt>3. 컬럼 마스킹(ASO)</dt>
<dd th:if="${!#lists.isEmpty(result.maskedColumns())}" th:text="${#strings.listJoin(result.maskedColumns(), ', ') + ' 컬럼이 마스킹 또는 NULL로 관찰됐습니다.'}">마스킹 컬럼</dd>
<dd th:if="${#lists.isEmpty(result.maskedColumns())}">응답에서 마스킹 또는 NULL로 관찰된 민감 컬럼이 없습니다. 원문 허용이거나 해당 컬럼이 결과에 없을 수 있습니다.</dd>
</div>
<div>
<dt>4. DB 감사 증적(FGA)</dt>
<dd th:text="${result.hasFgaExecutionEvidence() ? '이번 요청 ID와 일치하는 DB 감사 행을 찾았습니다.' : '이번 결과에 연결된 DB 감사 행은 찾지 못했습니다.'}">FGA 결과</dd>
</div>
</dl>
</div>
</section>
<section class="result-section">
<div class="section-heading compact-heading">
<h3>적용된 사용자와 권한</h3>

View File

@@ -70,6 +70,27 @@
<dt>원문 표시 허용</dt>
<dd>예외 사용자는 <a href="/user-masking-rules">컬럼 원문 표시 허용 사용자</a>에서 지정합니다.</dd>
</div>
<div>
<dt>DB 적용 상태 확인</dt>
<dd>아래 DB ASO 정책 동기화 표에서 백오피스 활성 컬럼과 실제 Redaction 컬럼이 일치하는지 확인합니다.</dd>
</div>
</dl>
</div>
<div class="effective-preview mt-3">
<div class="field-block-title">사용자별 실제 결과 해석</div>
<dl>
<div>
<dt>행 접근 권한 없음</dt>
<dd>VPD에서 행이 제외되므로 컬럼 마스킹 여부와 무관하게 조회 결과가 없습니다.</dd>
</div>
<div>
<dt>행 접근 허용 + 원문 허용 없음</dt>
<dd>행은 보이지만, 이 화면에서 연결한 민감 컬럼은 ASO/Data Redaction 정책에 따라 마스킹됩니다.</dd>
</div>
<div>
<dt>행 접근 허용 + 원문 허용 있음</dt>
<dd>행은 VPD 기준으로 제한되고, 원문 허용된 컬럼만 마스킹 없이 반환됩니다.</dd>
</div>
</dl>
</div>
</section>

View File

@@ -13,6 +13,48 @@
</details>
</div>
<section class="content-band">
<div class="section-heading">
<div>
<span class="architecture-kicker">운영 요약</span>
<h2>지금 먼저 확인할 상태</h2>
<p class="section-subtitle">아래 요약은 이 화면의 DB 조회 결과에서 계산한 운영자용 triage입니다. 최종 증적은 행별 상세와 접근 검증 결과로 확인합니다.</p>
</div>
<span class="badge"
th:classappend="${' ' + summary.overallBadgeClass()}"
th:text="${summary.overallLabel()}">정상</span>
</div>
<div class="effective-preview">
<dl>
<div>
<dt>전체 판단</dt>
<dd>
<strong th:text="${summary.overallLabel()}">정상</strong><br>
<small th:text="${summary.primaryAction()}">현재 요약 기준으로 즉시 조치할 항목은 없습니다.</small>
</dd>
</div>
<div>
<dt>행 접근 정책(VPD)</dt>
<dd th:text="${summary.rowPolicySummary()}">정상 0 · 확인 0 · 오류 0</dd>
</div>
<div>
<dt>컬럼 마스킹 정책(ASO)</dt>
<dd th:text="${summary.maskingSummary()}">적용 0 · 미적용 정상 0 · 불일치 0</dd>
</div>
<div>
<dt>검증 필요</dt>
<dd th:text="${summary.unverifiedCount() + '개 보호 객체'}">0개 보호 객체</dd>
</div>
</dl>
</div>
<div class="result-metrics mt-3">
<div><span>보호 객체</span><strong th:text="${summary.protectedObjectCount()}">0</strong></div>
<div><span>ORDS handler 없음</span><strong th:text="${summary.missingHandlerCount()}">0</strong></div>
<div><span>VPD 정책 없음</span><strong th:text="${summary.missingPolicyCount()}">0</strong></div>
<div><span>ASO 불일치</span><strong th:text="${summary.maskingMismatchCount()}">0</strong></div>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<div>

View File

@@ -182,6 +182,47 @@
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong><code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.</p>
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
</details>
<details class="explanation-details">
<summary>조건 코드가 실제 VPD WHERE 조각으로 바뀌는 방식</summary>
<p class="wizard-hint">이 표는 저장 규칙이 <code>CB_AGENT_DOC_VPD_FILTER</code>에서 어떤 predicate로 변환되는지 설명하기 위한 예시입니다. 실제 컬럼 존재 여부와 대상 객체는 DB에서 다시 검증됩니다.</p>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>저장 조건 코드</th><th>업무 의미</th><th>VPD filter가 만드는 조건</th></tr></thead>
<tbody>
<tr>
<td><code>ALL</code></td>
<td>해당 객체의 전체 행 허용</td>
<td><code>1 = 1</code></td>
</tr>
<tr>
<td><code>TOKEN_SUBJECT</code></td>
<td>토큰으로 식별된 이해관계자 본인 행</td>
<td><code>USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
</tr>
<tr>
<td><code>OWN_CONTRACT</code></td>
<td>토큰 사용자가 담당 설계사인 계약</td>
<td><code>FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
</tr>
<tr>
<td><code>CHANNEL_CONTRACT</code></td>
<td>토큰 사용자의 채널에 속한 계약</td>
<td><code>FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')</code></td>
</tr>
<tr>
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
<td>계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행</td>
<td><code>EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)</code></td>
</tr>
<tr>
<td><code>STATIC_SQL</code></td>
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
<td><code>CONTRACT_STATUS = '정상'</code>처럼 검증된 현재 객체 컬럼 조건</td>
</tr>
</tbody>
</table>
</div>
</details>
</div>
</div>

View File

@@ -85,9 +85,10 @@
<div class="empty-result-guide">
<strong>검증 결과</strong>
<ul>
<li>토큰이 어떤 사용자와 역할로 해석됐는지</li>
<li>VPD 적용 후 실제로 몇 행이 보였는지</li>
<li>예상과 다를 때 어디를 확인해야 하는지</li>
<li>토큰이 어떤 사용자와 역할로 해석됐는지 확인합니다.</li>
<li>VPD 행 접근 정책을 지난 뒤 실제로 몇 행이 남았는지 확인합니다.</li>
<li>ASO/Data Redaction이 어떤 민감 컬럼을 마스킹했는지 확인합니다.</li>
<li>FGA 감사 증적이 있으면 DB가 실제 실행한 SQL과 VPD predicate를 확인합니다.</li>
</ul>
</div>
</section>

View File

@@ -0,0 +1 @@
mock-maker-subclass