fix #477: list vpd target objects

This commit is contained in:
devmrko
2026-06-26 05:34:56 +09:00
parent 6e4a306dcd
commit 91b03ed952
9 changed files with 173 additions and 4 deletions

View File

@@ -22,4 +22,5 @@
- 권한 관리 화면은 Backoffice Tables 레이어를 강조한다. - 권한 관리 화면은 Backoffice Tables 레이어를 강조한다.
- VPD 설정 화면은 Oracle Database VPD 레이어를 강조한다. - VPD 설정 화면은 Oracle Database VPD 레이어를 강조한다.
- ORDS Handler 생성과 ORDS 검증 화면은 ORDS 서빙/검증 레이어를 강조한다. - ORDS Handler 생성과 ORDS 검증 화면은 ORDS 서빙/검증 레이어를 강조한다.
- VPD 적용 대상은 TABLE/VIEW로 표기하고, ORDS path는 서빙/검증 화면에서만 노출한다. - VPD 적용 대상은 Oracle DB catalog 기준의 TABLE/VIEW 목록으로 표시한다.
- VPD 화면에서는 VPD 적용 여부를 중심으로 보여주고, 백오피스 권한 테이블 등록 여부와 ORDS Path는 서빙 상태 참고 정보로 함께 표시한다.

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.config;
import com.cloudhandson.vpdbackoffice.service.PermissionService; import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import java.sql.Connection; import java.sql.Connection;
import javax.sql.DataSource; import javax.sql.DataSource;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -17,15 +18,18 @@ public class DbPoolWarmup {
private final DataSource dataSource; private final DataSource dataSource;
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
private final PermissionService permissionService; private final PermissionService permissionService;
private final VpdPolicyService vpdPolicyService;
public DbPoolWarmup( public DbPoolWarmup(
DataSource dataSource, DataSource dataSource,
ProtectedObjectService protectedObjectService, ProtectedObjectService protectedObjectService,
PermissionService permissionService PermissionService permissionService,
VpdPolicyService vpdPolicyService
) { ) {
this.dataSource = dataSource; this.dataSource = dataSource;
this.protectedObjectService = protectedObjectService; this.protectedObjectService = protectedObjectService;
this.permissionService = permissionService; this.permissionService = permissionService;
this.vpdPolicyService = vpdPolicyService;
} }
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
@@ -45,6 +49,8 @@ public class DbPoolWarmup {
protectedObjectService.findDatabaseObjects(); protectedObjectService.findDatabaseObjects();
permissionService.findRoles(); permissionService.findRoles();
permissionService.findPermissionViews(); permissionService.findPermissionViews();
vpdPolicyService.findVpdTargets();
vpdPolicyService.formOptions();
log.info("Backoffice DB catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000); log.info("Backoffice DB catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
} }
} }

View File

@@ -0,0 +1,24 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdTargetView(
String owner,
String objectName,
String objectType,
String protectedYn,
String ordsPath,
int policyCount,
String policyNames
) {
public String objectDisplayName() {
return owner + "." + objectName;
}
public boolean protectedObject() {
return "Y".equalsIgnoreCase(protectedYn);
}
public boolean vpdApplied() {
return policyCount > 0;
}
}

View File

@@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -12,6 +13,8 @@ public interface VpdPolicyMapper {
List<VpdPolicyView> findPolicies(); List<VpdPolicyView> findPolicies();
List<VpdTargetView> findVpdTargets();
List<String> findPolicyNameOptions(); List<String> findPolicyNameOptions();
List<String> findSchemaOwnerOptions(); List<String> findSchemaOwnerOptions();

View File

@@ -8,6 +8,7 @@ import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyExplanation;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView;
import com.cloudhandson.vpdbackoffice.mapper.VpdPolicyMapper; import com.cloudhandson.vpdbackoffice.mapper.VpdPolicyMapper;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -21,10 +22,13 @@ import org.springframework.transaction.annotation.Transactional;
public class VpdPolicyService { public class VpdPolicyService {
private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX"); private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
private static final long CATALOG_CACHE_MILLIS = 60_000L;
private final VpdPolicyMapper mapper; private final VpdPolicyMapper mapper;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final OpenAiCompatibleClient aiClient; private final OpenAiCompatibleClient aiClient;
private volatile CacheEntry<List<VpdTargetView>> vpdTargetsCache;
private volatile CacheEntry<VpdPolicyFormOptions> formOptionsCache;
public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) { public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) {
this.mapper = mapper; this.mapper = mapper;
@@ -36,14 +40,30 @@ public class VpdPolicyService {
return mapper.findPolicies(); return mapper.findPolicies();
} }
public List<VpdTargetView> findVpdTargets() {
CacheEntry<List<VpdTargetView>> cached = vpdTargetsCache;
if (cached != null && !cached.expired()) {
return cached.value();
}
List<VpdTargetView> targets = List.copyOf(mapper.findVpdTargets());
vpdTargetsCache = new CacheEntry<>(targets, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
return targets;
}
public VpdPolicyFormOptions formOptions() { public VpdPolicyFormOptions formOptions() {
return new VpdPolicyFormOptions( CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache;
if (cached != null && !cached.expired()) {
return cached.value();
}
VpdPolicyFormOptions options = new VpdPolicyFormOptions(
mapper.findPolicyNameOptions(), mapper.findPolicyNameOptions(),
mapper.findSchemaOwnerOptions(), mapper.findSchemaOwnerOptions(),
mapper.findOwnerOptions(), mapper.findOwnerOptions(),
mapper.findFunctionOptions(), mapper.findFunctionOptions(),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX") List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
); );
formOptionsCache = new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
return options;
} }
public VpdPolicyFormOptions emptyFormOptions() { public VpdPolicyFormOptions emptyFormOptions() {
@@ -72,6 +92,7 @@ public class VpdPolicyService {
throw new AppException("Filter predicate는 필수입니다."); throw new AppException("Filter predicate는 필수입니다.");
} }
createFilterFunction(functionName, filterPredicate); createFilterFunction(functionName, filterPredicate);
clearCatalogCache();
} }
@Transactional @Transactional
@@ -93,6 +114,7 @@ public class VpdPolicyService {
END; END;
""", objectOwner, objectName, policyName); """, objectOwner, objectName, policyName);
createPolicy(command); createPolicy(command);
clearCatalogCache();
} }
public VpdBulkApplyResult bulkApplySchema( public VpdBulkApplyResult bulkApplySchema(
@@ -178,6 +200,7 @@ public class VpdPolicyService {
failed++; failed++;
} }
} }
clearCatalogCache();
return new VpdBulkApplyResult(targets.size(), created, skipped, failed); return new VpdBulkApplyResult(targets.size(), created, skipped, failed);
} }
@@ -246,6 +269,12 @@ public class VpdPolicyService {
command.enabled(), command.enabled(),
command.updateCheck() command.updateCheck()
); );
clearCatalogCache();
}
public void clearCatalogCache() {
vpdTargetsCache = null;
formOptionsCache = null;
} }
private void addPolicy( private void addPolicy(
@@ -496,6 +525,13 @@ public class VpdPolicyService {
return value.replace("'", "''"); return value.replace("'", "''");
} }
private record CacheEntry<T>(T value, long expiresAt) {
boolean expired() {
return System.currentTimeMillis() > expiresAt;
}
}
private record FunctionRef(String owner, String packageName, String functionName) { private record FunctionRef(String owner, String packageName, String functionName) {
} }

View File

@@ -39,12 +39,14 @@ public class VpdPolicyController {
private void populatePolicyModel(Model model) { private void populatePolicyModel(Model model) {
try { try {
model.addAttribute("policies", vpdPolicyService.findPolicies()); model.addAttribute("policies", vpdPolicyService.findPolicies());
model.addAttribute("vpdTargets", vpdPolicyService.findVpdTargets());
model.addAttribute("objects", protectedObjectService.findEnabled()); model.addAttribute("objects", protectedObjectService.findEnabled());
model.addAttribute("formOptions", vpdPolicyService.formOptions()); model.addAttribute("formOptions", vpdPolicyService.formOptions());
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception); RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
model.addAttribute("runtimeError", message); model.addAttribute("runtimeError", message);
model.addAttribute("policies", List.of()); model.addAttribute("policies", List.of());
model.addAttribute("vpdTargets", List.of());
model.addAttribute("objects", List.of()); model.addAttribute("objects", List.of());
model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions()); model.addAttribute("formOptions", vpdPolicyService.emptyFormOptions());
} }

View File

@@ -38,6 +38,45 @@
ORDER BY p.object_owner, p.object_name, p.policy_name ORDER BY p.object_owner, p.object_name, p.policy_name
</select> </select>
<select id="findVpdTargets" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView">
WITH managed_owners AS (
SELECT USER AS owner FROM dual
UNION
SELECT owner FROM cb_protected_object
),
managed_objects AS (
SELECT owner, object_name, object_type
FROM all_objects
WHERE object_type IN ('TABLE', 'VIEW')
AND owner IN (SELECT owner FROM managed_owners)
AND owner NOT IN ('SYS', 'SYSTEM', 'ORDS_METADATA', 'ORDS_PUBLIC_USER')
AND owner NOT LIKE 'APEX\_%' ESCAPE '\'
AND owner NOT LIKE 'C##%'
AND object_name NOT LIKE 'BIN$%'
)
SELECT o.owner,
o.object_name,
o.object_type,
CASE WHEN po.object_id IS NULL THEN 'N' ELSE po.enabled_yn END AS protected_yn,
po.ords_path,
COUNT(p.policy_name) AS policy_count,
LISTAGG(p.policy_name, ', ') WITHIN GROUP (ORDER BY p.policy_name) AS policy_names
FROM managed_objects o
LEFT JOIN cb_protected_object po
ON po.owner = o.owner
AND po.object_name = o.object_name
AND po.enabled_yn = 'Y'
LEFT JOIN all_policies p
ON p.object_owner = o.owner
AND p.object_name = o.object_name
GROUP BY o.owner, o.object_name, o.object_type, po.object_id, po.enabled_yn, po.ords_path
ORDER BY CASE WHEN COUNT(p.policy_name) > 0 THEN 0 ELSE 1 END,
o.owner,
o.object_type,
o.object_name
FETCH FIRST 500 ROWS ONLY
</select>
<select id="findPolicyNameOptions" resultType="string"> <select id="findPolicyNameOptions" resultType="string">
SELECT DISTINCT policy_name SELECT DISTINCT policy_name
FROM all_policies FROM all_policies

View File

@@ -305,7 +305,7 @@ body {
.section-heading h2 { .section-heading h2 {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
margin: 0 0 1rem; margin: 0;
} }
.section-heading { .section-heading {
@@ -313,6 +313,13 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem;
}
.section-subtitle {
color: var(--rw-muted);
font-size: .875rem;
margin: .2rem 0 0;
} }
.form-grid { .form-grid {

View File

@@ -26,6 +26,57 @@
<p class="text-muted mb-0">VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 정책을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.</p> <p class="text-muted mb-0">VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 정책을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.</p>
</section> </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는 이 객체를 HTTP로 서빙하는 별도 레이어입니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Object</th>
<th>Type</th>
<th>VPD 상태</th>
<th>Policy</th>
<th>백오피스 권한 테이블</th>
<th>ORDS Path</th>
</tr>
</thead>
<tbody>
<tr th:each="target : ${vpdTargets}">
<td><code th:text="${target.objectDisplayName()}">ADMIN.TABLE</code></td>
<td th:text="${target.objectType()}">TABLE</td>
<td>
<span class="badge"
th:classappend="${target.vpdApplied()} ? ' text-bg-success' : ' text-bg-secondary'"
th:text="${target.vpdApplied()} ? 'VPD 적용됨' : '미적용'">미적용</span>
</td>
<td>
<span th:if="${target.vpdApplied()}" th:text="${target.policyNames()}">POLICY</span>
<span th:unless="${target.vpdApplied()}" class="text-muted">-</span>
</td>
<td>
<span class="badge"
th:classappend="${target.protectedObject()} ? ' text-bg-primary' : ' text-bg-light'"
th:text="${target.protectedObject()} ? '등록됨' : '미등록'">미등록</span>
</td>
<td>
<code th:if="${target.ordsPath()}" th:text="${target.ordsPath()}">cb-ords/path</code>
<span th:unless="${target.ordsPath()}" class="text-muted">ORDS 서빙 미등록</span>
</td>
</tr>
<tr th:if="${#lists.isEmpty(vpdTargets)}">
<td colspan="6" class="text-muted">조회 가능한 TABLE/VIEW가 없습니다. DB 연결 사용자 권한과 스키마 객체를 확인하세요.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>개별 적용</h2> <h2>개별 적용</h2>