From 9f63adeca5f988deb8ec7a886a5a834e5bbcb3cf Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 26 Jun 2026 10:26:09 +0900 Subject: [PATCH] fix #490: align vpd policy apply hierarchy --- .../490-vpd-policy-apply-hierarchy/README.md | 44 ++++++++++++ .../service/VpdPolicyService.java | 13 ++-- .../web/VpdPolicyController.java | 5 +- src/main/resources/static/css/app.css | 51 ++++++++++++++ src/main/resources/static/js/app.js | 20 +++++- .../resources/templates/vpd-policies.html | 68 +++++++++++++------ 6 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 docs/design/490-vpd-policy-apply-hierarchy/README.md diff --git a/docs/design/490-vpd-policy-apply-hierarchy/README.md b/docs/design/490-vpd-policy-apply-hierarchy/README.md new file mode 100644 index 0000000..ed9fcd4 --- /dev/null +++ b/docs/design/490-vpd-policy-apply-hierarchy/README.md @@ -0,0 +1,44 @@ +# Redmine #490 - VPD Policy/Filter/적용 계층 정리 설계 + +## 프로젝트 개요 + +VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하기 위한 Spring Boot 관리 도구다. VPD 설정 화면은 Oracle DB의 TABLE/VIEW에 VPD policy를 적용하고, policy는 filter function을 참조한다. ORDS는 VPD가 적용된 TABLE/VIEW를 HTTP API로 서빙하는 별도 레이어다. + +## 목표 + +`VPD 설정` 화면에서 개별 적용과 벌크 적용이 같은 계층으로 보이게 정리한다. + +정리할 계층: + +1. `Filter Function`: 행 predicate를 반환하는 PL/SQL 함수 +2. `Policy Template`: policy name, filter function, statement type, enabled/check option 조합 +3. `TABLE/VIEW 적용`: 선택한 policy template을 개별 객체 또는 스키마 객체 목록에 적용 + +## 문제 + +현재 벌크 적용 폼은 `VPD Filter Function`과 `Filter predicate`를 직접 입력받는다. 이 표현은 filter function을 TABLE/VIEW에 직접 붙이는 것처럼 보이고, 개별 적용의 `Policy / Filter` 선택 흐름과도 다르다. + +## 설계 + +- 개별 적용과 벌크 적용 모두 `Policy Template` 선택을 중심으로 한다. +- 선택한 template에서 `policyName`, `functionKey`, statement type, enabled, update check 값을 hidden field와 checkbox로 동기화한다. +- 벌크 적용 backend는 선택한 `policyName`을 명시적으로 받아 각 대상 TABLE/VIEW에 같은 policy name으로 적용한다. +- filter function 자동 생성 입력은 이번 화면에서 제거한다. filter/policy 관리는 `Filter Policy 관리` 메뉴에서 수행한다. +- 선택한 template의 policy name, filter function, statement types를 preview로 표시한다. + +## 완료 기준 + +- VPD 설정 화면에서 개별 적용과 벌크 적용 모두 `적용할 Policy Template`을 선택한다. +- 벌크 적용 화면에서 `VPD Filter Function`, `Filter predicate` 직접 입력이 사라진다. +- 선택한 template의 policy/function/statement/enabled/check option preview가 표시된다. +- controller/service가 벌크 적용 시 선택된 policy name과 function key를 사용한다. +- 기존 개별 적용 동작은 유지된다. + +## 검증 + +- `mvn test` +- Playwright 화면 확인: + - `/vpd-policies` 개별 적용에 policy template preview 표시 + - `/vpd-policies` 벌크 적용에 policy template select 표시 + - 벌크 적용에 filter function 직접 선택/textarea가 없음 + - 390px 모바일 body overflow 없음 diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java index 60075ed..1732d01 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VpdPolicyService.java @@ -139,6 +139,7 @@ public class VpdPolicyService { String schemaOwner, boolean includeTables, boolean includeViews, + String policyNameValue, String functionKey, String functionOwnerValue, String functionNameValue, @@ -160,6 +161,9 @@ public class VpdPolicyService { throw new AppException("선택한 스키마에서 VPD 적용 대상 TABLE/VIEW를 찾을 수 없습니다: " + owner); } + String bulkPolicyName = policyNameValue == null || policyNameValue.isBlank() + ? COMMON_POLICY_NAME + : requiredIdentifier(policyNameValue, "Policy name"); FunctionRef functionRef = parseFunctionRef(defaultFunctionKey(functionKey)); String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim(); if (functionRef == null && filterPredicate.isBlank()) { @@ -197,8 +201,7 @@ public class VpdPolicyService { int skipped = 0; int failed = 0; for (VpdSchemaObjectOption target : targets) { - String policyName = generatedPolicyName(target.objectName()); - if (mapper.findAnyPolicy(target.owner(), target.objectName(), policyName) != null) { + if (mapper.findAnyPolicy(target.owner(), target.objectName(), bulkPolicyName) != null) { skipped++; continue; } @@ -206,7 +209,7 @@ public class VpdPolicyService { addPolicy( target.owner(), target.objectName(), - policyName, + bulkPolicyName, functionOwner, packageName == null ? functionName : packageName + "." + functionName, statementTypes, @@ -538,10 +541,6 @@ public class VpdPolicyService { return generated.length() > 128 ? generated.substring(0, 128) : generated; } - private String generatedPolicyName(String objectName) { - return COMMON_POLICY_NAME; - } - private FunctionRef parseFunctionRef(String functionKey) { if (functionKey == null || functionKey.isBlank()) { return null; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java index 1292c83..22be6d1 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/VpdPolicyController.java @@ -184,6 +184,7 @@ public class VpdPolicyController { @RequestParam String schemaOwner, @RequestParam(defaultValue = "false") boolean includeTables, @RequestParam(defaultValue = "false") boolean includeViews, + @RequestParam(required = false) String policyName, @RequestParam(required = false) String functionKey, @RequestParam(required = false) String functionOwner, @RequestParam(required = false) String functionName, @@ -193,7 +194,7 @@ public class VpdPolicyController { @RequestParam(required = false) String filterPredicate, RedirectAttributes redirectAttributes ) { - bulkApplyPolicyInternal(schemaOwner, includeTables, includeViews, functionKey, functionOwner, functionName, + bulkApplyPolicyInternal(schemaOwner, includeTables, includeViews, policyName, functionKey, functionOwner, functionName, statementTypes, enabled, updateCheck, filterPredicate, redirectAttributes); return "redirect:/vpd-policies"; } @@ -202,6 +203,7 @@ public class VpdPolicyController { String schemaOwner, boolean includeTables, boolean includeViews, + String policyName, String functionKey, String functionOwner, String functionName, @@ -216,6 +218,7 @@ public class VpdPolicyController { schemaOwner, includeTables, includeViews, + policyName, functionKey, functionOwner, functionName, diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 03d0c82..464527d 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -520,6 +520,57 @@ body { margin: 0; } +.policy-apply-flow { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: .4rem; + margin-bottom: .75rem; +} + +.policy-apply-flow span { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 999px; + color: var(--rw-text); + font-size: .82rem; + font-weight: 800; + min-height: 2rem; + padding: .35rem .7rem; +} + +.policy-apply-flow strong { + color: var(--rw-muted); +} + +.policy-template-preview dl { + display: grid; + gap: .75rem; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + margin: 0; +} + +.policy-template-preview dl > div { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + min-width: 0; + padding: .7rem; +} + +.policy-template-preview dt { + color: var(--rw-muted); + font-size: .74rem; + font-weight: 800; + text-transform: uppercase; +} + +.policy-template-preview dd { + font-weight: 700; + margin: .25rem 0 0; + overflow-wrap: anywhere; +} + .bulk-apply-summary { cursor: pointer; font-weight: 700; diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index bf6261c..3ccb841 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -34,6 +34,7 @@ function syncPolicyTemplate(select) { return; } const option = select.options[select.selectedIndex]; + const hasTemplate = Boolean(option?.dataset.policyName); const policyName = form.querySelector('[data-policy-template-field="policyName"]'); const functionKey = form.querySelector('[data-policy-template-field="functionKey"]'); const enabled = form.querySelector('[data-policy-template-field="enabled"]'); @@ -45,18 +46,31 @@ function syncPolicyTemplate(select) { functionKey.value = option?.dataset.functionKey || ''; } if (enabled) { - enabled.checked = option?.dataset.enabled === 'true'; + enabled.checked = hasTemplate ? option?.dataset.enabled === 'true' : true; } if (updateCheck) { - updateCheck.checked = option?.dataset.updateCheck === 'true'; + updateCheck.checked = hasTemplate ? option?.dataset.updateCheck === 'true' : false; } - const selectedStatements = (option?.dataset.statementTypes || '') + const selectedStatements = (hasTemplate ? option?.dataset.statementTypes || '' : 'SELECT') .split(',') .map((value) => value.trim()) .filter(Boolean); form.querySelectorAll('[data-policy-template-statement]').forEach((checkbox) => { checkbox.checked = selectedStatements.includes(checkbox.value); }); + const preview = (name, value) => { + const target = form.querySelector(`[data-policy-template-preview="${name}"]`); + if (target) { + target.textContent = value; + } + }; + preview('policyName', option?.dataset.policyName || '선택 전'); + preview('functionKey', option?.dataset.functionKey || '선택 전'); + preview('statementTypes', selectedStatements.join(', ') || 'SELECT'); + preview( + 'options', + `Enabled: ${enabled?.checked ? 'YES' : 'NO'} / Check: ${updateCheck?.checked ? 'YES' : 'NO'}` + ); } function closeMenuGroup(group) { diff --git a/src/main/resources/templates/vpd-policies.html b/src/main/resources/templates/vpd-policies.html index 4d74eaa..1b287ab 100644 --- a/src/main/resources/templates/vpd-policies.html +++ b/src/main/resources/templates/vpd-policies.html @@ -23,7 +23,14 @@

VPD 적용

Filter Policy 관리 -

VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 정책을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.

+
+ Filter Function + + Policy Template + + TABLE/VIEW 적용 +
+

VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 Policy Template을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.

@@ -192,9 +199,9 @@
@@ -257,39 +272,52 @@ - + + +
Statement Types
- +
- +
- +

벌크 적용은 선택한 Policy Template을 스키마의 TABLE/VIEW 목록에 적용합니다. Filter Function을 직접 선택하거나 predicate를 입력하지 않습니다.