fix #557: guide permission-driven VPD flow

This commit is contained in:
devmrko
2026-06-29 07:01:53 +09:00
parent 8dc77f87ab
commit fbe3d4682b
24 changed files with 1351 additions and 489 deletions

View File

@@ -213,7 +213,7 @@ body {
align-items: stretch;
display: grid;
gap: .5rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
margin-bottom: 1rem;
min-width: 0;
}
@@ -1508,3 +1508,323 @@ body {
padding-right: .65rem;
}
}
/* Guided permission journey (#557) */
.guided-hero {
max-width: 920px;
}
.guided-hero h1 {
font-size: clamp(1.75rem, 3.2vw, 2.5rem);
line-height: 1.18;
margin-top: .35rem;
}
.guided-hero p {
font-size: 1rem;
line-height: 1.65;
max-width: 850px;
}
.journey-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 1rem;
}
.journey-card {
align-items: flex-start;
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
box-shadow: var(--rw-shadow);
color: inherit;
display: flex;
gap: 1rem;
min-width: 0;
padding: 1.15rem;
text-decoration: none;
}
.journey-card:hover {
border-color: var(--rw-primary);
color: inherit;
transform: translateY(-1px);
}
.journey-number {
align-items: center;
background: var(--rw-primary-soft);
border-radius: 999px;
color: var(--rw-primary);
display: inline-flex;
flex: 0 0 2.2rem;
font-weight: 800;
height: 2.2rem;
justify-content: center;
}
.journey-card h2 {
font-size: 1.05rem;
font-weight: 800;
margin: .1rem 0 .35rem;
}
.journey-card p {
color: var(--rw-muted);
line-height: 1.5;
margin: 0 0 .75rem;
}
.journey-card strong {
color: var(--rw-primary);
font-size: .88rem;
}
.macro-micro-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.macro-micro-grid > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
min-width: 0;
padding: 1rem;
}
.macro-micro-grid h2,
.macro-micro-grid h3 {
font-size: 1rem;
font-weight: 800;
margin: .3rem 0 .45rem;
}
.macro-micro-grid p {
color: var(--rw-muted);
line-height: 1.55;
margin: 0;
}
.guided-check-intro {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.guided-check-intro h2 {
margin: .25rem 0 .4rem;
}
.guided-check-intro p {
color: var(--rw-muted);
margin: 0;
max-width: 760px;
}
.probe-form {
align-items: end;
}
.probe-submit {
min-height: 2.8rem;
}
.empty-result-guide {
color: var(--rw-muted);
}
.empty-result-guide ol {
margin: .65rem 0 0;
padding-left: 1.25rem;
}
.probe-result-flow {
display: grid;
gap: 1rem;
}
.probe-result-flow > .section-heading {
margin-bottom: 0;
}
.probe-summary {
color: var(--rw-text);
font-size: 1rem;
line-height: 1.6;
margin: 0;
}
.result-section {
border-top: 1px solid var(--rw-border);
padding-top: 1rem;
}
.result-section h3,
.next-action-card h3,
.probe-exchange h3 {
font-size: .92rem;
font-weight: 800;
margin: 0;
}
.result-metrics {
display: grid;
gap: .75rem;
grid-template-columns: minmax(120px, .5fr) minmax(0, 1.5fr);
}
.result-metrics > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .8rem;
}
.result-metrics span {
color: var(--rw-muted);
display: block;
font-size: .76rem;
font-weight: 700;
}
.result-metrics strong {
display: block;
margin-top: .25rem;
overflow-wrap: anywhere;
}
.next-action-card {
background: var(--rw-primary-soft);
border: 1px solid rgba(122, 62, 47, .2);
border-radius: 8px;
padding: 1rem;
}
.next-action-card p {
line-height: 1.55;
margin: .45rem 0 .8rem;
}
.technical-details,
.advanced-details {
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .8rem;
}
.technical-details > summary,
.advanced-details > summary {
cursor: pointer;
font-weight: 800;
}
.issued-token-card {
background: var(--rw-primary-soft);
border: 1px solid rgba(122, 62, 47, .25);
border-radius: 8px;
box-shadow: var(--rw-shadow);
margin-bottom: 1rem;
padding: 1.15rem;
}
.issued-token-card h2 {
font-size: 1.05rem;
font-weight: 800;
margin: .3rem 0 .65rem;
}
.issued-token-card .token-value {
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .75rem;
}
.default-vpd-form {
grid-template-columns: minmax(220px, 1.2fr) minmax(260px, 1.5fr) auto;
}
.default-policy-summary,
.protected-function-card {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
display: grid;
gap: .2rem;
min-width: 0;
padding: .7rem .85rem;
}
.default-policy-summary span,
.protected-function-card span {
color: var(--rw-muted);
font-size: .8rem;
}
.advanced-guidance {
background: #fff8e8;
border: 1px solid #e7d29c;
border-radius: 8px;
padding: 1rem;
}
.advanced-guidance h2 {
margin-bottom: .6rem;
}
.advanced-guidance p {
margin: .3rem 0 0;
}
.advanced-guidance ul {
margin: 0;
padding-left: 1.25rem;
}
.advanced-guidance li + li {
margin-top: .45rem;
}
.protected-function-card {
grid-template-columns: auto minmax(180px, auto) 1fr;
margin-top: 1rem;
}
.filter-replace-form {
display: grid;
gap: .4rem;
grid-template-columns: minmax(150px, .8fr) minmax(220px, 1fr) auto;
min-width: 520px;
}
@media (max-width: 920px) {
.journey-grid,
.macro-micro-grid,
.default-vpd-form {
grid-template-columns: 1fr;
}
.guided-check-intro {
align-items: flex-start;
flex-direction: column;
}
.protected-function-card {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.journey-card {
padding: .9rem;
}
.result-metrics {
grid-template-columns: 1fr;
}
}

View File

@@ -115,6 +115,44 @@ function initPersistentMenus() {
});
}
function initQuestionPresets(root = document) {
root.querySelectorAll('.question-preset[data-target]').forEach((button) => {
if (button.dataset.presetReady === 'true') {
return;
}
button.dataset.presetReady = 'true';
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
}
function initCopyButtons(root = document) {
root.querySelectorAll('[data-copy-source]').forEach((button) => {
if (button.dataset.copyReady === 'true') {
return;
}
button.dataset.copyReady = 'true';
button.addEventListener('click', async () => {
const source = document.getElementById(button.dataset.copySource || '');
const value = source?.textContent?.trim();
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
button.textContent = '복사했습니다';
} catch (error) {
button.textContent = '복사 실패 · 직접 선택하세요';
}
});
});
}
function escapeHtml(value) {
return value
.replaceAll('&', '&')
@@ -811,6 +849,8 @@ function initVpdTargetFilters() {
document.addEventListener('DOMContentLoaded', () => {
initPersistentMenus();
initQuestionPresets();
initCopyButtons();
renderMarkdownViews();
initVpdTargetFilters();
const master = document.getElementById('userRoleMaster');

View File

@@ -1,64 +1,93 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('VPD 백오피스')}"></head>
<head th:replace="~{fragments/layout :: head('VPD 권한 백오피스')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>VPD 권한 백오피스</h1>
<p>Oracle Database의 VPD/ORDS 기능을 권한 테이블과 운영 UI로 제어하고 검증합니다.</p>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('')}"></section>
<header class="page-title guided-hero">
<span class="architecture-kicker">권한을 정하면 DB가 그대로 지킵니다</span>
<h1>누가 어떤 데이터를 볼 수 있는지 설계하고, 실제 결과까지 확인하세요.</h1>
<p>사용자·그룹·역할에 권한을 연결하면 Oracle VPD가 요청할 때마다 그 규칙을 읽어 허용된 행만 반환합니다. 별도 SQL 필터를 만드는 일은 예외적인 고급 작업입니다.</p>
</header>
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
<div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
<div th:text="${runtimeErrorMessage}"></div>
<div class="mt-2" th:if="${showSupportCommand}">
<code>./run.sh backoffice-support</code>
</div>
<div class="mt-2" th:if="${showSupportCommand}"><code>./run.sh backoffice-support</code></div>
</div>
<section class="summary-grid">
<a class="summary-tile" href="/permissions">
<span class="label">보호 객체</span>
<strong th:text="${#lists.size(objects)}">0</strong>
<section class="journey-grid" aria-label="권한 적용 네 단계">
<a class="journey-card" href="/permissions">
<span class="journey-number">1</span>
<div>
<h2>1. 권한 설계</h2>
<p>사용자와 그룹에 역할을 주고, 역할마다 볼 수 있는 객체·행·컬럼을 정합니다.</p>
<strong>권한 규칙 만들기 →</strong>
</div>
</a>
<a class="summary-tile" href="/permissions">
<span class="label">역할</span>
<strong th:text="${#lists.size(roles)}">0</strong>
<a class="journey-card" href="/vpd-policies">
<span class="journey-number">2</span>
<div>
<h2>2. DB 보호 연결</h2>
<p>보호할 TABLE/VIEW를 고르면 동적 VPD가 1단계의 권한체계를 자동으로 적용합니다.</p>
<strong>보호 객체 연결하기 →</strong>
</div>
</a>
<a class="summary-tile" href="/tokens">
<span class="label">토큰</span>
<strong th:text="${#lists.size(tokens)}">0</strong>
<a class="journey-card" href="/tokens">
<span class="journey-number">3</span>
<div>
<h2>3. 토큰 발급</h2>
<p>검증할 사용자를 선택해 토큰을 발급하고, 한 번만 보이는 원문을 복사합니다.</p>
<strong>테스트 토큰 발급하기 →</strong>
</div>
</a>
<a class="journey-card" href="/probe">
<span class="journey-number">4</span>
<div>
<h2>4. 결과 확인</h2>
<p>토큰으로 ORDS를 호출해 사용자·상속 역할과 실제로 보이는 행을 함께 확인합니다.</p>
<strong>권한 결과 확인하기 →</strong>
</div>
</a>
</section>
<section class="content-band macro-micro-grid">
<div>
<span class="architecture-kicker">MACRO · 전체 관점</span>
<h2>권한체계가 유일한 기준입니다.</h2>
<p>일상적인 변경은 사용자, 그룹, 역할, 권한 규칙에서만 합니다. 같은 규칙을 화면과 DB 필터에 이중으로 작성하지 않습니다.</p>
</div>
<div>
<span class="architecture-kicker">MICRO · 실행 관점</span>
<h2>VPD가 요청마다 조건을 계산합니다.</h2>
<p>토큰에서 사용자를 찾고 직접 역할과 그룹 상속 역할을 합친 뒤, 객체의 ALLOW/DENY 및 행·열 규칙을 적용합니다. 근거는 마지막 검증 단계에서 확인합니다.</p>
</div>
</section>
<section class="summary-grid" aria-label="현재 등록 현황">
<a class="summary-tile" href="/permissions"><span class="label">보호 객체</span><strong th:text="${#lists.size(objects)}">0</strong></a>
<a class="summary-tile" href="/roles"><span class="label">역할</span><strong th:text="${#lists.size(roles)}">0</strong></a>
<a class="summary-tile" href="/tokens"><span class="label">발급 이력</span><strong th:text="${#lists.size(tokens)}">0</strong></a>
</section>
<section class="content-band">
<div class="section-heading">
<h2>최근 보호 객체</h2>
<a class="btn btn-sm rw-btn-secondary" href="/probe">검증 실행</a>
<div>
<h2>현재 검증 가능한 데이터</h2>
<p class="section-subtitle">권한 규칙과 ORDS 경로가 등록된 보호 객체입니다.</p>
</div>
<a class="btn btn-sm rw-btn-primary" href="/probe">결과 확인</a>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Owner</th>
<th>Object</th>
<th>ORDS Path</th>
<th>Status</th>
</tr>
</thead>
<thead><tr><th>데이터 객체</th><th>검증 경로</th><th>상태</th></tr></thead>
<tbody>
<tr th:each="object : ${objects}">
<td th:text="${object.owner()}">ADMIN</td>
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
<td><code th:text="${object.ordsPath()}">search/documents</code></td>
<td><span class="badge text-bg-success">enabled</span></td>
</tr>
<tr th:if="${#lists.isEmpty(objects)}">
<td colspan="4" class="text-muted">등록된 보호 객체가 없습니다.</td>
<td><strong th:text="${object.displayName()}">ADMIN.OBJECT</strong></td>
<td><code th:text="${object.ordsPath()}">path</code></td>
<td><span class="badge text-bg-success">사용 가능</span></td>
</tr>
<tr th:if="${#lists.isEmpty(objects)}"><td colspan="3" class="text-muted">아직 검증할 보호 객체가 없습니다. 1단계에서 객체 권한을 먼저 등록하세요.</td></tr>
</tbody>
</table>
</div>

View File

@@ -16,27 +16,28 @@
<a class="navbar-brand" href="/">VPD Backoffice</a>
<div class="rw-menu">
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">권한 관리</button>
<button class="rw-menu-trigger" type="button" aria-expanded="false">1. 권한 설계</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/users">사용자</a>
<a class="nav-link" href="/groups">그룹</a>
<a class="nav-link" href="/roles">역할</a>
<a class="nav-link" href="/effective-matrix">유효 권한</a>
<a class="nav-link" href="/permissions">권한</a>
<a class="nav-link" href="/tokens">토큰</a>
<a class="nav-link" href="/permissions">데이터 권한 규칙</a>
<a class="nav-link" href="/effective-matrix">사용자별 최종 권한</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">ORDS 서빙</button>
<button class="rw-menu-trigger" type="button" aria-expanded="false">2. 보호·검증</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/objects">조회 Handler 생성</a>
<a class="nav-link" href="/probe">ORDS 검증</a>
<a class="nav-link" href="/vpd-policies">DB 보호 연결</a>
<a class="nav-link" href="/tokens">테스트 토큰 발급</a>
<a class="nav-link" href="/probe">권한 결과 확인</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">연동 도구</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/objects">ORDS 조회 대상</a>
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">MCP</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/mcp-chatbot">Chatbot</a>
<a class="nav-link" href="/mcp-reasoning">Reasoning</a>
<a class="nav-link" href="/mcp-sse">SSE 서비스</a>
@@ -44,11 +45,10 @@
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">운영</button>
<button class="rw-menu-trigger" type="button" aria-expanded="false">운영·고급</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/operation-status">운영 상태</a>
<a class="nav-link" href="/vpd-policies">VPD 설정</a>
<a class="nav-link" href="/vpd-filter-policies">Filter Policy 관리</a>
<a class="nav-link" href="/vpd-filter-policies">별도 Filter 관리</a>
<a class="nav-link" href="/settings">설정</a>
</div>
</div>
@@ -60,23 +60,29 @@
</div>
</nav>
<section th:fragment="architectureStrip(activeLayer)" class="architecture-strip" aria-label="VPD Backoffice architecture">
<section th:fragment="architectureStrip(activeLayer)" class="architecture-strip" aria-label="권한 설정부터 결과 확인까지의 흐름">
<div class="architecture-step" th:classappend="${activeLayer == 'permission'} ? ' active'">
<span class="architecture-kicker">Backoffice Tables</span>
<strong>권한 테이블</strong>
<p>사용자, 그룹, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.</p>
<span class="architecture-kicker">1 · WHO / WHAT</span>
<strong>권한 설계</strong>
<p>누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
<span class="architecture-kicker">Oracle Database</span>
<strong>VPD Policy</strong>
<p>TABLE/VIEW에 policy function을 붙여 DB에서 행 접근을 제한합니다.</p>
<span class="architecture-kicker">2 · ENFORCE</span>
<strong>DB 보호 연결</strong>
<p>VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'token'} ? ' active'">
<span class="architecture-kicker">3 · IDENTITY</span>
<strong>토큰 발급</strong>
<p>검증할 사용자를 나타내는 일회성 원문 토큰을 준비합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">
<span class="architecture-kicker">Oracle REST Data Services</span>
<strong>ORDS 서빙/검증</strong>
<p>VPD가 적용된 TABLE/VIEW를 HTTP API로 호출해 결과를 확인합니다.</p>
<span class="architecture-kicker">4 · EVIDENCE</span>
<strong>결과 확인</strong>
<p>ORDS를 호출해 그 사용자에게 실제로 보이는 데이터만 확인합니다.</p>
</div>
</section>
</body>

View File

@@ -1,58 +1,98 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="result">
<div th:fragment="result" class="probe-result-flow">
<div class="section-heading">
<h2>검증 결과</h2>
<span class="badge" th:classappend="${result.status().name() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'"
th:text="${result.status()}">SUCCESS</span>
<div>
<span class="architecture-kicker">검증 결론</span>
<h2 th:text="${result.title()}">권한 결과</h2>
</div>
<span class="badge"
th:classappend="${result.successLike()} ? ' text-bg-success' : ' text-bg-warning'"
th:text="${result.successLike()} ? '검증 완료' : '확인 필요'">검증 완료</span>
</div>
<div class="alert alert-warning" th:if="${result.errorCode()}">
<strong th:text="${result.errorCode()}">ERROR</strong>
<span th:text="${result.errorMessage()}">message</span>
</div>
<p class="probe-summary" th:text="${result.plainSummary()}">결과 설명</p>
<div class="meta-row">
<span>Rows: <strong th:text="${result.rowCount()}">0</strong></span>
<span th:if="${!#lists.isEmpty(result.maskedColumns())}">
Masked: <code th:text="${#strings.listJoin(result.maskedColumns(), ', ')}"></code>
</span>
</div>
<section class="result-section">
<div class="section-heading compact-heading">
<h3>적용된 사용자와 권한</h3>
<span class="badge text-bg-light" th:if="${tokenContext}" th:text="${tokenContext.statusLabel()}">ACTIVE</span>
</div>
<div class="effective-preview" th:if="${tokenContext}">
<dl>
<div><dt>사용자</dt><dd th:text="${tokenContext.username()}">user</dd></div>
<div>
<dt>직접 받은 역할</dt>
<dd th:if="${!#lists.isEmpty(tokenContext.directRoles())}" th:text="${#strings.listJoin(tokenContext.directRoles(), ', ')}">ROLE</dd>
<dd th:if="${#lists.isEmpty(tokenContext.directRoles())}" class="text-muted">없음</dd>
</div>
<div>
<dt>소속 그룹</dt>
<dd th:if="${!#lists.isEmpty(tokenContext.groups())}" th:text="${#strings.listJoin(tokenContext.groups(), ', ')}">GROUP</dd>
<dd th:if="${#lists.isEmpty(tokenContext.groups())}" class="text-muted">없음</dd>
</div>
<div>
<dt>그룹에서 상속된 역할</dt>
<dd th:if="${!#lists.isEmpty(tokenContext.inheritedRoles())}" th:text="${#strings.listJoin(tokenContext.inheritedRoles(), ', ')}">ROLE</dd>
<dd th:if="${#lists.isEmpty(tokenContext.inheritedRoles())}" class="text-muted">없음</dd>
</div>
</dl>
</div>
<div class="alert alert-light mb-0" th:unless="${tokenContext}">
토큰에서 사용자를 찾지 못해 역할과 그룹을 계산하지 않았습니다.
</div>
</section>
<div class="probe-exchange-grid">
<section class="probe-exchange">
<h3>Request Headers</h3>
<pre th:text="${result.requestHeaders()} ?: '{}'">{}</pre>
</section>
<section class="probe-exchange">
<h3>Request Payload</h3>
<pre th:text="${result.requestPayload()} ?: '{}'">{}</pre>
</section>
<section class="probe-exchange">
<h3>Response Headers</h3>
<pre th:text="${result.responseHeaders()} ?: '{}'">{}</pre>
</section>
<section class="probe-exchange">
<h3>Response Body</h3>
<pre th:text="${result.responseBody()} ?: ''"></pre>
</section>
</div>
<section class="result-section">
<div class="section-heading compact-heading">
<h3>VPD가 적용된 실제 결과</h3>
<span th:if="${selectedObject}"><code th:text="${selectedObject.displayName()}">ADMIN.OBJECT</code></span>
</div>
<div class="result-metrics">
<div><span>보이는 행</span><strong th:text="${result.rowCount()}">0</strong></div>
<div>
<span>NULL/마스킹으로 확인된 컬럼</span>
<strong th:if="${!#lists.isEmpty(result.maskedColumns())}" th:text="${#strings.listJoin(result.maskedColumns(), ', ')}">column</strong>
<strong th:if="${#lists.isEmpty(result.maskedColumns())}">없음</strong>
</div>
</div>
<div class="table-responsive" th:if="${!#lists.isEmpty(result.rows())}">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th th:each="column : ${result.columns()}" th:text="${column}">column</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${result.rows()}">
<td th:each="column : ${result.columns()}" th:text="${row.get(column)}">value</td>
</tr>
</tbody>
</table>
</div>
<div class="table-responsive mt-3" th:if="${!#lists.isEmpty(result.rows())}">
<table class="table table-sm table-striped align-middle">
<thead><tr><th th:each="column : ${result.columns()}" th:text="${column}">column</th></tr></thead>
<tbody>
<tr th:each="row : ${result.rows()}">
<td th:each="column : ${result.columns()}" th:text="${row.get(column)}">value</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="next-action-card">
<h3>다음에 할 일</h3>
<p th:text="${result.nextAction()}">다음 행동</p>
<div class="action-stack">
<a class="btn btn-sm rw-btn-secondary" href="/effective-matrix">사용자별 최종 권한 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/tokens" th:if="${result.status().name() == 'TOKEN_NOT_FOUND' || result.status().name() == 'TOKEN_INACTIVE'}">새 토큰 발급</a>
</div>
</section>
<details class="technical-details">
<summary>기술 상세: 오류 코드와 HTTP 요청·응답 보기</summary>
<div class="alert alert-warning mt-3" th:if="${result.errorCode()}">
<strong th:text="${result.errorCode()}">ERROR</strong>
<span th:text="${result.errorMessage()}">message</span>
</div>
<div class="probe-exchange-grid mt-3">
<section class="probe-exchange"><h3>Request Headers</h3><pre th:text="${result.requestHeaders()} ?: '{}'">{}</pre></section>
<section class="probe-exchange"><h3>Request Payload</h3><pre th:text="${result.requestPayload()} ?: '{}'">{}</pre></section>
<section class="probe-exchange"><h3>Response Headers</h3><pre th:text="${result.responseHeaders()} ?: '{}'">{}</pre></section>
<section class="probe-exchange"><h3>Response Body</h3><pre th:text="${result.responseBody()} ?: ''"></pre></section>
</div>
</details>
</div>
</body>
</html>

View File

@@ -1,96 +1,67 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('ORDS 검증')}"></head>
<head th:replace="~{fragments/layout :: head('권한 결과 확인')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>ORDS 검증</h1>
<p>ORDS가 서빙하는 TABLE/VIEW 호출 결과로 VPD/Redaction 적용 여부를 확인합니다.</p>
<h1>권한 결과 확인</h1>
<p>한 사용자의 토큰으로 실제 데이터를 요청해, 설계한 권한이 DB에서 그대로 적용되는지 확인합니다.</p>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
<section class="content-band guided-check-intro">
<div>
<span class="architecture-kicker">이번 단계에서 확인하는 것</span>
<h2>“이 사용자는 이 데이터에서 무엇을 볼 수 있는가?”</h2>
<p>토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD가 저장된 행·열 규칙을 적용한 결과를 보여줍니다.</p>
</div>
<a class="btn rw-btn-secondary" href="/tokens">테스트 토큰이 없나요? 먼저 발급하기</a>
</section>
<section class="content-band">
<div class="section-heading">
<h2>ORDS 호출</h2>
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a>
<div>
<h2>검증할 사용자와 데이터 입력</h2>
<p class="section-subtitle">등록 토큰 목록에서 고르는 대신 발급할 때 복사한 원문 하나만 사용합니다.</p>
</div>
</div>
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid token-form">
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid probe-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
등록 토큰
<select class="form-select" name="tokenKeyId" data-token-context-select>
<option value="">원문 직접 입력</option>
<option th:each="token : ${tokens}"
th:value="${token.keyId()}"
th:text="${token.displayLabel()}"
th:attr="data-username=${token.username()},
data-prefix=${token.maskedToken()},
data-status=${token.statusLabel()},
data-expires-at=${token.expiresAt()},
data-description=${token.description()},
data-direct-roles=${#strings.listJoin(token.directRoles(), '|')},
data-groups=${#strings.listJoin(token.groups(), '|')},
data-inherited-roles=${#strings.listJoin(token.inheritedRoles(), '|')}"></option>
</select>
<label class="span-2">
1. 발급받은 토큰 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요" required>
<span class="form-hint">원문은 DB에 저장되지 않습니다. 목록에 보이는 prefix만으로는 검증할 수 없으며, 원문을 잃었다면 새 토큰을 발급해야 합니다.</span>
</label>
<label>
Bearer Token 원문
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
<span class="form-hint">등록 토큰을 선택해도 원문은 저장되지 않아 실제 호출 시 필요합니다.</span>
</label>
<aside class="token-context-preview effective-preview span-2" data-token-context-preview>
<div class="section-heading compact-heading">
<h3>선택 토큰 컨텍스트</h3>
<span class="badge text-bg-secondary" data-token-preview="status">미선택</span>
</div>
<dl>
<div>
<dt>사용자</dt>
<dd data-token-preview="username">원문 직접 입력</dd>
</div>
<div>
<dt>Prefix</dt>
<dd><code data-token-preview="prefix">-</code></dd>
</div>
<div>
<dt>만료</dt>
<dd data-token-preview="expiresAt">-</dd>
</div>
<div>
<dt>직접 역할</dt>
<dd data-token-preview="directRoles">-</dd>
</div>
<div>
<dt>그룹</dt>
<dd data-token-preview="groups">-</dd>
</div>
<div>
<dt>그룹 상속 역할</dt>
<dd data-token-preview="inheritedRoles">-</dd>
</div>
</dl>
<p class="form-hint" data-token-preview="description">토큰을 선택하면 등록된 사용자/역할 컨텍스트를 먼저 확인할 수 있습니다.</p>
</aside>
<label>
ORDS 트랙
2. 확인할 데이터
<select class="form-select" name="objectId" required>
<option th:each="object : ${objects}"
th:value="${object.objectId()}"
th:text="${object.displayName() + ' / ' + object.ordsPath()}"></option>
th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
</select>
<span class="form-hint">이 객체에 저장한 권한 규칙과 실제 반환 행을 비교합니다.</span>
</label>
<label>
Limit
최대 확인 행 수
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
</label>
<button class="btn rw-btn-primary" type="submit">호출</button>
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
</form>
</section>
<section id="probe-result" class="content-band">
<div class="text-muted">검증 결과가 여기에 표시됩니다.</div>
<section id="probe-result" class="content-band" aria-live="polite">
<div class="empty-result-guide">
<strong>결과는 세 가지 순서로 설명합니다.</strong>
<ol>
<li>토큰이 어떤 사용자와 역할로 해석됐는지</li>
<li>VPD 적용 후 실제로 몇 행이 보였는지</li>
<li>예상과 다를 때 어디를 확인해야 하는지</li>
</ol>
</div>
</section>
</main>
</body>

View File

@@ -1,22 +1,35 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Bearer Token')}"></head>
<head th:replace="~{fragments/layout :: head('테스트 토큰 발급')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>Bearer Token</h1>
<p>토큰은 발급 직후 한 번만 원문을 표시합니다.</p>
<h1>테스트 토큰 발급</h1>
<p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다.</p>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('token')}"></section>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-warning" th:if="${issued}">
<div class="fw-semibold">새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다.</div>
<code class="token-value" th:text="${issued.plainToken()}"></code>
</div>
<section class="issued-token-card" th:if="${issued}" th:attr="data-issued-key-id=${issued.keyId()}">
<span class="architecture-kicker">발급 완료 · 지금 한 번만 표시됩니다</span>
<h2>이 토큰 원문을 복사한 뒤 권한 결과 확인으로 이동하세요.</h2>
<code class="token-value" id="issued-token-value" th:text="${issued.plainToken()}"></code>
<div class="action-stack mt-3">
<button class="btn rw-btn-primary" type="button" data-copy-source="issued-token-value">토큰 원문 복사</button>
<a class="btn rw-btn-secondary" href="/probe">권한 결과 확인으로 이동</a>
</div>
<p class="form-hint mb-0">화면을 떠난 뒤에는 원문을 다시 볼 수 없습니다. 잃어버리면 기존 토큰을 복구하지 말고 새로 발급하세요.</p>
</section>
<section class="content-band">
<h2>토큰 발급</h2>
<div class="section-heading">
<div>
<h2>1. 검증할 사용자 선택</h2>
<p class="section-subtitle">토큰에는 사용자의 직접 역할과 그룹 상속 역할이 연결됩니다. 권한 자체를 토큰에 복사하지 않으므로 이후 권한 변경도 동적으로 반영됩니다.</p>
</div>
</div>
<form method="post" action="/tokens" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
@@ -30,32 +43,31 @@
<input class="form-control" name="expiresAt" type="datetime-local" th:value="${defaultExpiresAt}" required>
</label>
<label>
설명
<input class="form-control" name="description" maxlength="200">
용도 메모
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
</label>
<button class="btn btn-primary" type="submit">발급</button>
<button class="btn rw-btn-primary" type="submit">2. 토큰 발급</button>
</form>
</section>
<section class="content-band">
<h2>토큰 목록</h2>
<div class="section-heading">
<div>
<h2>발급 이력</h2>
<p class="section-subtitle">보안상 prefix와 상태만 보관합니다. 이 목록에서는 원문을 복사하거나 복구할 수 없습니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tokens)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Prefix</th>
<th>Expires</th>
<th>Revoked</th>
<th></th>
</tr>
</thead>
<thead><tr><th>ID</th><th>사용자</th><th>용도</th><th>식별용 Prefix</th><th>만료</th><th>회수</th><th></th></tr></thead>
<tbody>
<tr th:each="token : ${tokens}">
<tr th:each="token : ${tokens}"
th:attr="data-key-id=${token.keyId()},data-description=${token.description()},data-revoked=${token.revokedAt() != null}">
<td th:text="${token.keyId()}">1</td>
<td th:text="${token.username()}">user</td>
<td><code th:text="${token.keyPrefix()}">vpd_live_x</code></td>
<td th:text="${token.description()} ?: '-'">용도</td>
<td><code th:text="${token.keyPrefix() + '****'}">vpd_****</code></td>
<td th:text="${token.expiresAt()}">2026</td>
<td th:text="${token.revokedAt()} ?: '-'">-</td>
<td>
@@ -67,9 +79,7 @@
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(tokens)}">
<td colspan="6" class="text-muted">등록된 토큰이 없습니다.</td>
</tr>
<tr th:if="${#lists.isEmpty(tokens)}"><td colspan="7" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr>
</tbody>
</table>
</div>

View File

@@ -1,12 +1,13 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Filter Policy 관리')}"></head>
<head th:replace="~{fragments/layout :: head('별도 Filter 고급 관리')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>Filter Policy 관리</h1>
<p>Filter function과 적용된 VPD policy를 관리합니다. TABLE/VIEW 개별/벌크 적용은 VPD 설정에서 처리합니다.</p>
<span class="badge text-bg-warning">고급 설정</span>
<h1 class="mt-2">별도 Filter 관리</h1>
<p>일반 권한은 이 화면에서 만들지 않습니다. 권한체계로 표현할 수 없는 객체 전용 조건이 있을 때만 별도 함수를 추가합니다.</p>
</div>
<div class="alert alert-warning" th:if="${runtimeError}">
@@ -16,173 +17,161 @@
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}">등록되었습니다.</div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">처리할 수 없습니다.</div>
<section class="content-band">
<section class="content-band default-filter-explainer">
<div class="section-heading">
<h2>Filter 등록/수정</h2>
<a class="btn btn-sm rw-btn-secondary" href="/vpd-policies">VPD 적용 화면</a>
</div>
<form method="post" action="/vpd-filter-policies/filters" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
Function Owner
<select class="form-select" name="functionOwner">
<option value="">현재 연결 사용자</option>
<option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option>
</select>
</label>
<label>
Function 이름
<input class="form-control" name="functionName" list="filter-function-names"
placeholder="예: BOARD_POSTS_FILTER" required>
</label>
<label class="span-2">
Filter predicate
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
</label>
<div class="question-presets span-2" aria-label="Filter predicate 예시">
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">전체 차단</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=1">전체 허용</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">부서 일치</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="owner_emp_no = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')">본인 소유</button>
<div>
<span class="architecture-kicker">기본 동작: 권한체계 자동 반영</span>
<h2>대부분의 변경은 권한 규칙 화면에서 끝납니다.</h2>
</div>
<button class="btn rw-btn-primary" type="submit">Filter 저장</button>
</form>
<datalist id="filter-function-names">
<option th:each="function : ${formOptions.functions()}" th:value="${function.functionName()}"></option>
</datalist>
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 규칙으로 이동</a>
</div>
<div class="macro-micro-grid">
<div>
<h3>운영자가 관리하는 것</h3>
<p>사용자, 그룹, 역할, 객체별 ALLOW/DENY, 행 조건과 컬럼 공개 범위를 관리합니다.</p>
</div>
<div>
<h3>DB가 자동으로 하는 것</h3>
<p><code>CB_AGENT_DOC_VPD_FILTER</code>가 요청마다 그 권한을 합쳐 predicate를 만들고, 권한이나 유효한 컨텍스트가 없으면 <code>1=0</code>으로 차단합니다.</p>
</div>
</div>
<div class="protected-function-card">
<strong>보호되는 핵심 함수</strong>
<code>CB_AGENT_DOC_VPD_FILTER</code>
<span>이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다.</span>
</div>
</section>
<section class="content-band">
<div class="section-heading">
<h2>Filter 목록</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Function</th>
<th>Type</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<th:block th:each="function, iter : ${formOptions.functions()}">
<tr>
<td><code th:text="${function.value()}">ADMIN.FILTER</code></td>
<td th:text="${function.objectType()}">FUNCTION</td>
<td>
<button class="btn btn-sm rw-btn-secondary"
type="button"
th:hx-get="@{/vpd-policies/function-source(owner=${function.owner()},packageName=${function.packageName()},functionName=${function.functionName()})}"
th:hx-target="${'#filter-source-' + iter.index}"
hx-swap="innerHTML">
Source 보기
</button>
</td>
</tr>
<tr>
<td colspan="3" class="policy-source-cell">
<div class="filter-edit-grid">
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">
Source 보기를 누르면 현재 function source가 표시됩니다.
</div>
<form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="functionOwner" th:value="${function.owner()}">
<input type="hidden" name="functionName" th:value="${function.functionName()}">
<label>
수정할 Filter predicate
<textarea class="form-control" name="filterPredicate" rows="3"
th:id="${'filter-edit-predicate-' + iter.index}"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
</label>
<div class="question-presets" aria-label="Filter predicate 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="1=0">전체 차단</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="1=1">전체 허용</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">부서 일치</button>
<details class="advanced-details">
<summary>고급: 별도 Filter 만들기</summary>
<div class="advanced-guidance mt-3">
<h2>먼저 이 기준을 확인하세요.</h2>
<ul>
<li><strong>사용 가능:</strong> 권한 테이블로 표현할 수 없는 객체 고유 조건을 별도 함수로 격리할 때</li>
<li><strong>사용 금지:</strong> 부서, 본인, 특정 값, 역할별 허용처럼 기존 권한 규칙이 지원하는 조건을 중복 작성할 때</li>
<li><strong>안전 원칙:</strong> 컨텍스트가 없거나 값이 잘못되면 반드시 <code>1=0</code>인 fail-closed 결과를 사용</li>
<li><strong>검증 원칙:</strong> 허용·거부·빈 컨텍스트·예상 밖 값 테스트를 만든 뒤 한 객체에 먼저 적용</li>
</ul>
</div>
<form method="post" action="/vpd-filter-policies/filters" class="form-grid mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
Function owner
<select class="form-select" name="functionOwner">
<option value="">현재 연결 사용자</option>
<option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option>
</select>
</label>
<label>
새 Function 이름
<input class="form-control" name="functionName" placeholder="예: BOARD_POSTS_SPECIAL_FILTER" required>
<span class="form-hint">CB_AGENT_DOC_VPD_FILTER 이름은 사용할 수 없습니다.</span>
</label>
<label class="span-2">
반환할 predicate
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
placeholder="예: tenant_id = SYS_CONTEXT('CB_AGENT_CTX', 'TENANT_ID')" required></textarea>
</label>
<div class="question-presets span-2" aria-label="Filter predicate 안전 예시">
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">안전한 기본 차단</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">컨텍스트 비교 예시</button>
</div>
<button class="btn rw-btn-primary" type="submit">별도 Filter 저장</button>
</form>
<div class="section-heading mt-4">
<h2>설치된 Filter 함수</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>Function</th><th>구분</th><th>Source / 변경</th></tr></thead>
<tbody>
<th:block th:each="function, iter : ${formOptions.functions()}">
<tr>
<td><code th:text="${function.value()}">ADMIN.FILTER</code></td>
<td>
<span class="badge text-bg-primary" th:if="${function.permissionSystemDefault()}">권한체계 기본</span>
<span class="badge text-bg-warning" th:unless="${function.permissionSystemDefault()}">별도 Filter</span>
</td>
<td>
<button class="btn btn-sm rw-btn-secondary" type="button"
th:hx-get="@{/vpd-policies/function-source(owner=${function.owner()},packageName=${function.packageName()},functionName=${function.functionName()})}"
th:hx-target="${'#filter-source-' + iter.index}" hx-swap="innerHTML">Source 보기</button>
</td>
</tr>
<tr>
<td colspan="3" class="policy-source-cell">
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">Source 보기를 누르면 현재 함수 내용을 표시합니다.</div>
<div class="alert alert-light mt-3 mb-0" th:if="${function.permissionSystemDefault()}">
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
</div>
<button class="btn btn-sm rw-btn-primary" type="submit">Filter 수정</button>
</form>
</div>
</td>
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(formOptions.functions())}">
<td colspan="3" class="text-muted">등록된 VPD filter function 후보가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form mt-3"
th:unless="${function.permissionSystemDefault()}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="functionOwner" th:value="${function.owner()}">
<input type="hidden" name="functionName" th:value="${function.functionName()}">
<label>
이 별도 Filter의 새 predicate
<textarea class="form-control" name="filterPredicate" rows="3" required></textarea>
</label>
<button class="btn btn-sm rw-btn-secondary" type="submit">별도 Filter 수정</button>
</form>
</td>
</tr>
</th:block>
<tr th:if="${#lists.isEmpty(formOptions.functions())}"><td colspan="3" class="text-muted">설치된 VPD 함수가 없습니다.</td></tr>
</tbody>
</table>
</div>
</details>
</section>
<section class="content-band">
<div class="section-heading">
<h2>적용된 Policy 목록/수정</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Object</th>
<th>Policy</th>
<th>Function</th>
<th>Status</th>
<th>수정</th>
</tr>
</thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td>
<td><code th:text="${policy.policyName()}">POLICY</code></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td>
<td th:text="${policy.enabled()}">YES</td>
<td>
<form method="post" action="/vpd-filter-policies/replace" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="oldObjectKey" th:value="${policy.objectDisplayName()}">
<input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}">
<input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}">
<input class="form-control form-control-sm" name="policyName" th:value="${policy.policyName()}">
<select class="form-select form-select-sm" name="functionKey">
<option th:each="function : ${formOptions.functions()}"
th:value="${function.value()}"
th:text="${function.value()}"
th:selected="${function.value() == policy.functionDisplayName()}"></option>
</select>
<input type="hidden" name="enabled" value="true">
<input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'">
<button class="btn btn-sm rw-btn-secondary" type="submit">수정</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(policies)}">
<td colspan="5" class="text-muted">등록된 VPD policy가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<details class="advanced-details">
<summary>고급: 적용된 Policy를 별도 Filter로 교체</summary>
<div class="advanced-guidance mt-3">
<strong>영향 범위</strong>
<p>기존 policy를 삭제한 뒤 새 policy를 등록합니다. 기본 권한체계 경로에서 벗어나므로 대상 객체, 거부 조건, 복구할 기본 함수와 검증 토큰을 먼저 준비하세요.</p>
</div>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead><tr><th>Object</th><th>현재 Policy</th><th>현재 Function</th><th>구분</th><th>교체</th></tr></thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td>
<td><code th:text="${policy.policyName()}">POLICY</code></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td>
<td>
<span class="badge text-bg-primary" th:if="${policy.permissionSystemDefault()}">권한체계 자동</span>
<span class="badge text-bg-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
</td>
<td>
<form method="post" action="/vpd-filter-policies/replace" class="filter-replace-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="oldObjectKey" th:value="${policy.objectDisplayName()}">
<input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}">
<input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}">
<input class="form-control form-control-sm" name="policyName" th:value="${policy.policyName()}" aria-label="새 policy 이름">
<select class="form-select form-select-sm" name="functionKey" aria-label="새 filter function">
<option th:each="function : ${formOptions.functions()}"
th:value="${function.value()}" th:text="${function.value()}"
th:selected="${function.value() == policy.functionDisplayName()}"></option>
</select>
<input type="hidden" name="enabled" value="true">
<input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'">
<button class="btn btn-sm btn-outline-warning" type="submit">영향 확인 후 교체</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(policies)}"><td colspan="5" class="text-muted">적용된 VPD policy가 없습니다.</td></tr>
</tbody>
</table>
</div>
</details>
</section>
</main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
</script>
</body>
</html>

View File

@@ -5,8 +5,8 @@
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>VPD 설정</h1>
<p>Oracle Database의 TABLE/VIEW에 VPD policy와 policy function/filter predicate를 적용합니다.</p>
<h1>DB 보호 연결</h1>
<p>권한 화면에서 만든 사용자·그룹·역할·행·열 규칙을 Oracle VPD가 실제 TABLE/VIEW에 적용하도록 연결합니다.</p>
</div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
@@ -20,24 +20,29 @@
<section class="content-band">
<div class="section-heading">
<h2>VPD 적용</h2>
<a class="btn btn-sm rw-btn-primary" href="/vpd-filter-policies">Filter Policy 관리</a>
<div>
<span class="architecture-kicker">기본 동작</span>
<h2>권한체계 자동 적용</h2>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 확인</a>
</div>
<div class="policy-apply-flow" aria-label="VPD 적용 계층">
<span>Filter Function</span>
<span>사용자·그룹·역할</span>
<strong></strong>
<span>Policy Template</span>
<span>객체별 행·열 권한</span>
<strong></strong>
<span>TABLE/VIEW 적용</span>
<span>동적 VPD</span>
<strong></strong>
<span>허용된 데이터만 반환</span>
</div>
<p class="text-muted mb-0">VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 Policy Template을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.</p>
<p class="text-muted mb-0"><code>CB_AGENT_DOC_VPD_FILTER</code>가 요청할 때마다 현재 권한체계를 읽습니다. 일상적인 권한 변경은 이 함수나 predicate가 아니라 <a href="/permissions">권한 규칙</a>에서 하세요.</p>
</section>
<section class="content-band">
<div class="section-heading">
<div>
<h2>VPD 적용 대상 TABLE/VIEW</h2>
<p class="section-subtitle">Oracle DB catalog 기준의 TABLE/VIEW 목록입니다. ORDS handler는 여러 객체를 조합할 수 있으므로 이 표의 ORDS 경로는 사용 여부가 아니라 백오피스에 등록된 검증용 경로입니다.</p>
<h2>보호할 수 있는 DB 객체</h2>
<p class="section-subtitle">VPD가 붙었는지, 권한 규칙과 검증 경로가 준비됐는지 한곳에서 확인합니다. 아래 기본 적용에서는 객체만 선택하면 됩니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span>
</div>
@@ -186,73 +191,40 @@
<section class="content-band">
<div class="section-heading">
<h2>개별 적용</h2>
<div>
<span class="architecture-kicker">권장 · 일반 작업</span>
<h2>객체 하나에 권한체계 연결</h2>
<p class="section-subtitle">객체만 선택하면 표준 동적 함수, SELECT 정책, 즉시 활성화를 서버가 안전한 기본값으로 적용합니다.</p>
</div>
</div>
<form method="post" action="/vpd-policies" class="form-grid">
<form method="post" action="/vpd-policies/default" class="form-grid default-vpd-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
VPD 적용 대상 TABLE/VIEW
보호할 TABLE/VIEW
<select class="form-select" name="objectKey" required>
<option th:each="target : ${vpdTargets}"
th:value="${target.objectDisplayName()}"
th:text="${target.objectDisplayName() + ' / ' + target.objectType() + (target.vpdApplied() ? ' / VPD 적용됨' : ' / 미적용')}"></option>
th:text="${target.objectDisplayName() + ' · ' + target.objectType() + (target.vpdApplied() ? ' · 이미 VPD 있음' : ' · 연결 필요')}"></option>
</select>
</label>
<label class="span-2">
적용할 Policy Template
<select class="form-select" data-policy-template-select required>
<option value="">등록된 policy template 선택</option>
<option th:each="template : ${formOptions.policyTemplates()}"
th:value="${template.label()}"
th:data-policy-name="${template.policyName()}"
th:data-function-key="${template.functionKey()}"
th:data-statement-types="${template.statementTypes()}"
th:data-enabled="${template.enabledValue()}"
th:data-update-check="${template.updateCheckValue()}"
th:text="${template.label()}"></option>
</select>
</label>
<input type="hidden" name="policyName" data-policy-template-field="policyName">
<input type="hidden" name="functionKey" data-policy-template-field="functionKey">
<aside class="policy-template-preview span-2" data-policy-template-summary>
<dl>
<div><dt>Policy</dt><dd data-policy-template-preview="policyName">선택 전</dd></div>
<div><dt>Filter Function</dt><dd data-policy-template-preview="functionKey">선택 전</dd></div>
<div><dt>Statements</dt><dd data-policy-template-preview="statementTypes">SELECT</dd></div>
<div><dt>Options</dt><dd data-policy-template-preview="options">Enabled: YES / Check: NO</dd></div>
</dl>
<aside class="default-policy-summary">
<strong>자동 적용 내용</strong>
<span>권한체계 동적 조회 · SELECT 보호 · 즉시 활성화 · 권한 없음은 차단</span>
</aside>
<div>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input" type="checkbox" name="statementTypes"
th:value="${statement}" th:checked="${statement == 'SELECT'}"
data-policy-template-statement>
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="vpd-enabled" type="checkbox" name="enabled" value="true" checked
data-policy-template-field="enabled">
<label class="form-check-label" for="vpd-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="vpd-update-check" type="checkbox" name="updateCheck" value="true"
data-policy-template-field="updateCheck">
<label class="form-check-label" for="vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<p class="text-muted small span-2 mb-0">Policy Template은 policy name과 filter function의 조합입니다. Filter Function은 Filter Policy 관리에서 등록/수정하고, 여기서는 선택한 template을 대상 TABLE/VIEW에 적용합니다.</p>
<button class="btn rw-btn-primary" type="submit">개별 객체에 VPD 적용</button>
<button class="btn rw-btn-primary" type="submit">권한체계 자동 적용</button>
</form>
</section>
<section class="content-band">
<details>
<summary class="bulk-apply-summary">벌크 적용</summary>
<summary class="bulk-apply-summary">고급: 여러 객체에 권한체계 일괄 연결</summary>
<div class="advanced-guidance mt-3">
<strong>언제 사용하나요?</strong>
<p>한 스키마의 TABLE/VIEW가 모두 같은 권한체계를 사용해야 하고, 대상 목록을 먼저 검토했을 때만 사용합니다. 기존 policy가 있는 객체는 건너뜁니다.</p>
</div>
<form method="post" action="/vpd-policies/bulk" class="form-grid mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="enabled" value="true">
<label>
Schema
<select class="form-select" name="schemaOwner" required>
@@ -272,60 +244,18 @@
</label>
</div>
</div>
<label class="span-2">
적용할 Policy Template
<select class="form-select" data-policy-template-select required>
<option value="">등록된 policy template 선택</option>
<option th:each="template : ${formOptions.policyTemplates()}"
th:value="${template.label()}"
th:data-policy-name="${template.policyName()}"
th:data-function-key="${template.functionKey()}"
th:data-statement-types="${template.statementTypes()}"
th:data-enabled="${template.enabledValue()}"
th:data-update-check="${template.updateCheckValue()}"
th:text="${template.label()}"></option>
</select>
</label>
<input type="hidden" name="policyName" data-policy-template-field="policyName">
<input type="hidden" name="functionKey" data-policy-template-field="functionKey">
<aside class="policy-template-preview span-2" data-policy-template-summary>
<dl>
<div><dt>Policy</dt><dd data-policy-template-preview="policyName">선택 전</dd></div>
<div><dt>Filter Function</dt><dd data-policy-template-preview="functionKey">선택 전</dd></div>
<div><dt>Statements</dt><dd data-policy-template-preview="statementTypes">SELECT</dd></div>
<div><dt>Options</dt><dd data-policy-template-preview="options">Enabled: YES / Check: NO</dd></div>
</dl>
</aside>
<div>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input" type="checkbox" name="statementTypes"
th:value="${statement}" th:checked="${statement == 'SELECT'}"
data-policy-template-statement>
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="bulk-vpd-enabled" type="checkbox" name="enabled" value="true" checked
data-policy-template-field="enabled">
<label class="form-check-label" for="bulk-vpd-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="bulk-vpd-update-check" type="checkbox" name="updateCheck" value="true"
data-policy-template-field="updateCheck">
<label class="form-check-label" for="bulk-vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<p class="text-muted small span-2 mb-0">벌크 적용은 선택한 Policy Template을 스키마의 TABLE/VIEW 목록에 적용합니다. Filter Function을 직접 선택하거나 predicate를 입력하지 않습니다.</p>
<button class="btn rw-btn-primary" type="submit">스키마 TABLE/VIEW에 VPD 일괄 적용</button>
<p class="text-muted small span-2 mb-0">모든 대상에 표준 <code>CB_PERMISSION_SELECT_POLICY</code><code>CB_AGENT_DOC_VPD_FILTER</code>를 적용합니다. 별도 predicate가 필요하면 운영·고급 메뉴의 가이드를 먼저 확인하세요.</p>
<button class="btn rw-btn-primary" type="submit">선택 스키마에 권한체계 일괄 연결</button>
</form>
</details>
</section>
<section class="content-band">
<div class="section-heading">
<h2>VPD Policies</h2>
<div>
<h2>현재 적용된 DB 보호 정책</h2>
<p class="section-subtitle">“권한체계 자동” 표시는 권한 규칙 변경을 요청 시점에 동적으로 반영하는 표준 정책입니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span>
</div>
@@ -368,6 +298,8 @@
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
</button>
<div class="text-muted small">클릭하면 filter source를 봅니다.</div>
<span class="badge text-bg-primary" th:if="${policy.permissionSystemDefault()}">권한체계 자동</span>
<span class="badge text-bg-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
</td>
<td th:text="${policy.statementTypes()} ?: '-'">SELECT</td>
<td>
@@ -416,16 +348,5 @@
</div>
</section>
</main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
</script>
</body>
</html>