fix #489: add vpd target filtering
This commit is contained in:
47
docs/design/489-vpd-target-filtering/README.md
Normal file
47
docs/design/489-vpd-target-filtering/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Redmine #489 - VPD 적용 대상 목록 필터/검색 설계
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하기 위한 Spring Boot 관리 도구다. 운영자는 Oracle catalog의 TABLE/VIEW 중 VPD 적용 대상과 권한 등록 상태를 확인하고, 개별 또는 벌크로 VPD policy를 적용한다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
`VPD 설정` 화면의 `VPD 적용 대상 TABLE/VIEW` 목록에서 업무 객체를 빠르게 찾을 수 있게 한다. DB catalog 원본은 유지하되, 기본 화면에서는 백오피스 지원 테이블을 숨기고 검색/필터/표시 건수 제어를 제공한다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
캡처 기준:
|
||||||
|
|
||||||
|
- `/tmp/vpd-ux-audit/screenshots/desktop-vpd-policies.png`
|
||||||
|
- `/tmp/vpd-ux-audit/screenshots/mobile-vpd-policies.png`
|
||||||
|
|
||||||
|
확인된 문제:
|
||||||
|
|
||||||
|
- 지원 테이블(`CB_APP_*`, `CB_PERMISSION*`, `CB_PROTECTED_*`, `CB_USER_*` 등)과 업무 TABLE/VIEW가 섞여 보인다.
|
||||||
|
- 목록이 길어 개별 적용 폼까지 이동하기 전에 운영자가 맥락을 잃는다.
|
||||||
|
- schema 조회는 있으나 object 검색, type, VPD 상태, 권한 등록 상태 필터가 없다.
|
||||||
|
|
||||||
|
## 설계
|
||||||
|
|
||||||
|
- 서버의 catalog 조회 결과는 그대로 둔다.
|
||||||
|
- 클라이언트 필터를 추가해 현재 페이지의 목록을 즉시 좁힌다.
|
||||||
|
- 기본값은 `백오피스 지원 테이블 제외`로 둔다.
|
||||||
|
- 지원 테이블 포함 여부, 검색어, object type, VPD 적용 상태, 권한 등록 상태, 표시 건수를 조합한다.
|
||||||
|
- 숨김은 primary row와 filter detail row를 한 쌍으로 처리한다.
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- 기본 화면에서 백오피스 지원 테이블이 숨겨진다.
|
||||||
|
- 사용자는 checkbox로 지원 테이블을 다시 포함할 수 있다.
|
||||||
|
- object 검색, TABLE/VIEW, VPD 상태, 권한 등록 상태, 표시 건수 필터가 동작한다.
|
||||||
|
- 필터 결과 건수와 전체 건수가 표시된다.
|
||||||
|
- 모바일 390px에서 body horizontal overflow가 없어야 한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `mvn test`
|
||||||
|
- Playwright:
|
||||||
|
- `/vpd-policies`에서 기본 표시 건수와 지원 테이블 숨김 확인
|
||||||
|
- 지원 테이블 포함 checkbox 동작 확인
|
||||||
|
- 검색어 `BOARD` 입력 시 matching row 확인
|
||||||
|
- 390px viewport overflow 확인
|
||||||
@@ -526,6 +526,38 @@ body {
|
|||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vpd-target-controls {
|
||||||
|
align-items: end;
|
||||||
|
background: var(--rw-surface-muted);
|
||||||
|
border: 1px solid var(--rw-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
grid-template-columns: minmax(min(220px, 100%), 1.5fr) repeat(4, minmax(min(120px, 100%), 1fr)) auto auto;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-target-controls label {
|
||||||
|
color: var(--rw-muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-support-toggle {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: .45rem;
|
||||||
|
min-height: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-target-count {
|
||||||
|
color: var(--rw-muted);
|
||||||
|
font-size: .82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.question-presets {
|
.question-presets {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1146,6 +1178,14 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vpd-target-controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-target-count {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.table-responsive > .table {
|
.table-responsive > .table {
|
||||||
min-width: 960px;
|
min-width: 960px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,9 +492,138 @@ function initPermissionWizard() {
|
|||||||
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
|
activatePermissionWizardStep(wizard, Number(wizard.dataset.currentStep || '1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backofficeSupportObjectPatterns = [
|
||||||
|
/^CB_APP_/,
|
||||||
|
/^CB_PERMISSION(_|$)/,
|
||||||
|
/^CB_PROTECTED_/,
|
||||||
|
/^CB_USER_/,
|
||||||
|
/^CB_GROUP_/,
|
||||||
|
/^CB_ORDS_/,
|
||||||
|
/^CB_AGENT_BEARER_/,
|
||||||
|
/^CB_BACKOFFICE_/,
|
||||||
|
/^APP_(GROUP|ROLE|USER)$/,
|
||||||
|
/^DB_SOURCE$/,
|
||||||
|
/^PERMISSION$/,
|
||||||
|
/^USER_GROUP$/
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBackofficeSupportObject(objectName) {
|
||||||
|
const normalized = (objectName || '').trim().toUpperCase();
|
||||||
|
return backofficeSupportObjectPatterns.some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVpdTargetFilters() {
|
||||||
|
const controls = document.querySelector('[data-vpd-target-controls]');
|
||||||
|
const body = document.querySelector('[data-vpd-target-body]');
|
||||||
|
if (!controls || !body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = Array.from(body.querySelectorAll('[data-vpd-target-row]')).map((row) => ({
|
||||||
|
row,
|
||||||
|
detail: row.nextElementSibling?.matches('[data-vpd-target-detail]') ? row.nextElementSibling : null,
|
||||||
|
object: (row.dataset.object || '').toUpperCase(),
|
||||||
|
objectName: (row.dataset.objectName || '').toUpperCase(),
|
||||||
|
objectType: (row.dataset.objectType || '').toUpperCase(),
|
||||||
|
vpdApplied: row.dataset.vpdApplied || 'false',
|
||||||
|
protectedObject: row.dataset.protectedObject || 'false'
|
||||||
|
}));
|
||||||
|
const search = controls.querySelector('[data-vpd-target-search]');
|
||||||
|
const type = controls.querySelector('[data-vpd-target-type]');
|
||||||
|
const vpd = controls.querySelector('[data-vpd-target-vpd]');
|
||||||
|
const protectedObject = controls.querySelector('[data-vpd-target-protected]');
|
||||||
|
const includeSupport = controls.querySelector('[data-vpd-target-include-support]');
|
||||||
|
const pageSize = controls.querySelector('[data-vpd-target-page-size]');
|
||||||
|
const visibleCount = controls.querySelector('[data-vpd-target-visible]');
|
||||||
|
const matchedCount = controls.querySelector('[data-vpd-target-matched]');
|
||||||
|
const totalCount = controls.querySelector('[data-vpd-target-total]');
|
||||||
|
const reset = controls.querySelector('[data-vpd-target-reset]');
|
||||||
|
const empty = document.createElement('tr');
|
||||||
|
empty.className = 'vpd-target-empty-runtime';
|
||||||
|
empty.innerHTML = '<td colspan="7" class="text-muted">필터 조건에 맞는 TABLE/VIEW가 없습니다.</td>';
|
||||||
|
body.appendChild(empty);
|
||||||
|
|
||||||
|
const matchesFilters = (entry) => {
|
||||||
|
const query = (search?.value || '').trim().toUpperCase();
|
||||||
|
if (query && !entry.object.includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type?.value && entry.objectType !== type.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (vpd?.value && entry.vpdApplied !== vpd.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (protectedObject?.value && entry.protectedObject !== protectedObject.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!includeSupport?.checked && isBackofficeSupportObject(entry.objectName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
const maxRows = pageSize?.value === 'ALL' ? Number.POSITIVE_INFINITY : Number(pageSize?.value || 50);
|
||||||
|
let matched = 0;
|
||||||
|
let visible = 0;
|
||||||
|
rows.forEach((entry) => {
|
||||||
|
const matchedRow = matchesFilters(entry);
|
||||||
|
if (matchedRow) {
|
||||||
|
matched += 1;
|
||||||
|
}
|
||||||
|
const show = matchedRow && visible < maxRows;
|
||||||
|
if (show) {
|
||||||
|
visible += 1;
|
||||||
|
}
|
||||||
|
entry.row.hidden = !show;
|
||||||
|
if (entry.detail) {
|
||||||
|
entry.detail.hidden = !show;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
empty.hidden = matched !== 0;
|
||||||
|
if (visibleCount) {
|
||||||
|
visibleCount.textContent = String(visible);
|
||||||
|
}
|
||||||
|
if (matchedCount) {
|
||||||
|
matchedCount.textContent = String(matched);
|
||||||
|
}
|
||||||
|
if (totalCount) {
|
||||||
|
totalCount.textContent = String(rows.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
[search, type, vpd, protectedObject, includeSupport, pageSize].forEach((control) => {
|
||||||
|
control?.addEventListener(control === search ? 'input' : 'change', applyFilters);
|
||||||
|
});
|
||||||
|
reset?.addEventListener('click', () => {
|
||||||
|
if (search) {
|
||||||
|
search.value = '';
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
type.value = '';
|
||||||
|
}
|
||||||
|
if (vpd) {
|
||||||
|
vpd.value = '';
|
||||||
|
}
|
||||||
|
if (protectedObject) {
|
||||||
|
protectedObject.value = '';
|
||||||
|
}
|
||||||
|
if (includeSupport) {
|
||||||
|
includeSupport.checked = false;
|
||||||
|
}
|
||||||
|
if (pageSize) {
|
||||||
|
pageSize.value = '50';
|
||||||
|
}
|
||||||
|
applyFilters();
|
||||||
|
search?.focus();
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initPersistentMenus();
|
initPersistentMenus();
|
||||||
renderMarkdownViews();
|
renderMarkdownViews();
|
||||||
|
initVpdTargetFilters();
|
||||||
const master = document.getElementById('userRoleMaster');
|
const master = document.getElementById('userRoleMaster');
|
||||||
if (master) {
|
if (master) {
|
||||||
master.addEventListener('change', filterUserRoleDetail);
|
master.addEventListener('change', filterUserRoleDetail);
|
||||||
|
|||||||
@@ -46,6 +46,56 @@
|
|||||||
<option value="">관리 대상 스키마</option>
|
<option value="">관리 대상 스키마</option>
|
||||||
<option th:each="owner : ${formOptions.schemaOwners()}" th:value="${owner}"></option>
|
<option th:each="owner : ${formOptions.schemaOwners()}" th:value="${owner}"></option>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
<div class="vpd-target-controls" data-vpd-target-controls th:if="${!#lists.isEmpty(vpdTargets)}">
|
||||||
|
<label>
|
||||||
|
Object 검색
|
||||||
|
<input class="form-control form-control-sm" type="search" placeholder="예: BOARD, CUSTOMER"
|
||||||
|
data-vpd-target-search>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Type
|
||||||
|
<select class="form-select form-select-sm" data-vpd-target-type>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="TABLE">TABLE</option>
|
||||||
|
<option value="VIEW">VIEW</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
VPD 상태
|
||||||
|
<select class="form-select form-select-sm" data-vpd-target-vpd>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="true">VPD 적용됨</option>
|
||||||
|
<option value="false">미적용</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
권한 등록
|
||||||
|
<select class="form-select form-select-sm" data-vpd-target-protected>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="true">등록됨</option>
|
||||||
|
<option value="false">미등록</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
표시
|
||||||
|
<select class="form-select form-select-sm" data-vpd-target-page-size>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50" selected>50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="ALL">전체</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-check vpd-support-toggle">
|
||||||
|
<input class="form-check-input" type="checkbox" data-vpd-target-include-support>
|
||||||
|
<span class="form-check-label">백오피스 지원 테이블 포함</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-sm rw-btn-secondary" type="button" data-vpd-target-reset>초기화</button>
|
||||||
|
<div class="vpd-target-count" aria-live="polite">
|
||||||
|
표시 <strong data-vpd-target-visible>0</strong>
|
||||||
|
/ 일치 <strong data-vpd-target-matched>0</strong>
|
||||||
|
/ 전체 <span data-vpd-target-total th:text="${#lists.size(vpdTargets)}">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle">
|
<table class="table table-sm align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -59,9 +109,14 @@
|
|||||||
<th>검증용 ORDS 경로</th>
|
<th>검증용 ORDS 경로</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody data-vpd-target-body>
|
||||||
<th:block th:each="target, iter : ${vpdTargets}">
|
<th:block th:each="target, iter : ${vpdTargets}">
|
||||||
<tr>
|
<tr data-vpd-target-row
|
||||||
|
th:attr="data-object=${target.objectDisplayName()},
|
||||||
|
data-object-name=${target.objectName()},
|
||||||
|
data-object-type=${target.objectType()},
|
||||||
|
data-vpd-applied=${target.vpdApplied()},
|
||||||
|
data-protected-object=${target.protectedObject()}">
|
||||||
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
|
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
|
||||||
<td th:text="${target.objectType()}">TABLE</td>
|
<td th:text="${target.objectType()}">TABLE</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -98,7 +153,7 @@
|
|||||||
<span th:unless="${target.ordsPath()}" class="text-muted">검증 경로 미등록</span>
|
<span th:unless="${target.ordsPath()}" class="text-muted">검증 경로 미등록</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${target.vpdApplied()}">
|
<tr th:if="${target.vpdApplied()}" data-vpd-target-detail>
|
||||||
<td colspan="7" class="policy-source-cell">
|
<td colspan="7" class="policy-source-cell">
|
||||||
<div th:id="${'target-filter-detail-' + iter.index}" class="text-muted small">
|
<div th:id="${'target-filter-detail-' + iter.index}" class="text-muted small">
|
||||||
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
|
Filter 이름을 클릭하면 policy/filter source가 표시됩니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user