fix #424: add VPD policy registration form

This commit is contained in:
devmrko
2026-06-25 16:45:08 +09:00
parent 7f12530edb
commit 1bcb6b6a43
4 changed files with 243 additions and 3 deletions

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdPolicyCreateCommand(
String objectOwner,
String objectName,
String policyName,
String functionOwner,
String functionName,
String statementTypes,
boolean enabled,
boolean updateCheck,
String filterPredicate
) {
}

View File

@@ -1,22 +1,30 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyDetail; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyDetail;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyExplanation; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyExplanation;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
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;
import java.util.Set;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class VpdPolicyService { public class VpdPolicyService {
private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
private final VpdPolicyMapper mapper; private final VpdPolicyMapper mapper;
private final JdbcTemplate jdbcTemplate;
private final OpenAiCompatibleClient aiClient; private final OpenAiCompatibleClient aiClient;
public VpdPolicyService(VpdPolicyMapper mapper, OpenAiCompatibleClient aiClient) { public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) {
this.mapper = mapper; this.mapper = mapper;
this.jdbcTemplate = jdbcTemplate;
this.aiClient = aiClient; this.aiClient = aiClient;
} }
@@ -45,6 +53,52 @@ public class VpdPolicyService {
return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy)); return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy));
} }
@Transactional
public void createPolicy(VpdPolicyCreateCommand command) {
String objectOwner = requiredIdentifier(command.objectOwner(), "Object owner");
String objectName = requiredIdentifier(command.objectName(), "Object name");
String policyName = requiredIdentifier(command.policyName(), "Policy name");
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
String functionOwner = command.functionOwner() == null || command.functionOwner().isBlank()
? currentUser
: requiredIdentifier(command.functionOwner(), "Function owner");
String functionName = command.functionName() == null || command.functionName().isBlank()
? generatedFunctionName(policyName)
: requiredIdentifier(command.functionName(), "Function name");
String statementTypes = normalizeStatementTypes(command.statementTypes());
String filterPredicate = command.filterPredicate() == null ? "" : command.filterPredicate().trim();
if (!filterPredicate.isBlank()) {
if (currentUser == null || !currentUser.equalsIgnoreCase(functionOwner)) {
throw new AppException("필터 함수 자동 생성은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
+ currentUser + ", Function owner: " + functionOwner);
}
createFilterFunction(functionName, filterPredicate);
}
jdbcTemplate.update("""
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => ?,
object_name => ?,
policy_name => ?,
function_schema => ?,
policy_function => ?,
statement_types => ?,
update_check => %s,
enable => %s,
policy_type => DBMS_RLS.DYNAMIC
);
END;
""".formatted(command.updateCheck() ? "TRUE" : "FALSE", command.enabled() ? "TRUE" : "FALSE"),
objectOwner,
objectName,
policyName,
functionOwner,
functionName,
statementTypes);
}
public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) { public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) {
VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName); VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName);
VpdPolicyView policy = detail.policy(); VpdPolicyView policy = detail.policy();
@@ -196,6 +250,49 @@ public class VpdPolicyService {
); );
} }
private void createFilterFunction(String functionName, String filterPredicate) {
jdbcTemplate.execute("""
CREATE OR REPLACE FUNCTION %s(
p_schema_name IN VARCHAR2,
p_object_name IN VARCHAR2
) RETURN VARCHAR2
AS
BEGIN
RETURN '%s';
END;
""".formatted(functionName, escapeSqlLiteral(filterPredicate)));
}
private String generatedFunctionName(String policyName) {
String base = policyName.endsWith("_POLICY")
? policyName.substring(0, policyName.length() - "_POLICY".length())
: policyName;
String generated = base + "_FILTER";
return generated.length() > 128 ? generated.substring(0, 128) : generated;
}
private String normalizeStatementTypes(String value) {
String raw = value == null || value.isBlank() ? "SELECT" : value;
List<String> statements = List.of(raw.split(",")).stream()
.map(String::trim)
.filter(token -> !token.isBlank())
.map(token -> token.toUpperCase(Locale.ROOT))
.toList();
if (statements.isEmpty()) {
return "SELECT";
}
for (String statement : statements) {
if (!ALLOWED_STATEMENTS.contains(statement)) {
throw new AppException("지원하지 않는 statement type입니다: " + statement);
}
}
return String.join(",", statements);
}
private String escapeSqlLiteral(String value) {
return value.replace("'", "''");
}
private String policyFunctionArgument(VpdPolicyView policy) { private String policyFunctionArgument(VpdPolicyView policy) {
if (policy.packageName() == null || policy.packageName().isBlank()) { if (policy.packageName() == null || policy.packageName().isBlank()) {
return policy.functionName(); return policy.functionName();

View File

@@ -1,35 +1,81 @@
package com.cloudhandson.vpdbackoffice.web; package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
import com.cloudhandson.vpdbackoffice.service.AppException; import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService; import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import java.util.List; import java.util.List;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller @Controller
public class VpdPolicyController { public class VpdPolicyController {
private final VpdPolicyService vpdPolicyService; private final VpdPolicyService vpdPolicyService;
private final ProtectedObjectService protectedObjectService;
public VpdPolicyController(VpdPolicyService vpdPolicyService) { public VpdPolicyController(VpdPolicyService vpdPolicyService, ProtectedObjectService protectedObjectService) {
this.vpdPolicyService = vpdPolicyService; this.vpdPolicyService = vpdPolicyService;
this.protectedObjectService = protectedObjectService;
} }
@GetMapping("/vpd-policies") @GetMapping("/vpd-policies")
public String policies(Model model) { public String policies(Model model) {
try { try {
model.addAttribute("policies", vpdPolicyService.findPolicies()); model.addAttribute("policies", vpdPolicyService.findPolicies());
model.addAttribute("objects", protectedObjectService.findEnabled());
} 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("objects", List.of());
} }
return "vpd-policies"; return "vpd-policies";
} }
@PostMapping("/vpd-policies")
public String createPolicy(
@RequestParam String objectKey,
@RequestParam String policyName,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") String statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
String[] objectParts = objectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("조회 대상 형식이 올바르지 않습니다: " + objectKey);
}
vpdPolicyService.createPolicy(new VpdPolicyCreateCommand(
objectParts[0],
objectParts[1],
policyName,
functionOwner,
functionName,
statementTypes,
enabled,
updateCheck,
filterPredicate
));
redirectAttributes.addFlashAttribute("successMessage", "VPD policy를 등록했습니다: " + policyName);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-policies";
}
@GetMapping("/vpd-policies/function-source") @GetMapping("/vpd-policies/function-source")
public String functionSource( public String functionSource(
@RequestParam String owner, @RequestParam String owner,

View File

@@ -6,13 +6,84 @@
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>VPD 설정</h1> <h1>VPD 설정</h1>
<p>ORDS 조회 Handler 대상에 등록된 보호 객체에 적용된 Oracle VPD policy 설정을 확인합니다.</p> <p>보호 객체에 Oracle VPD policy를 등록하고, policy function/filter predicate를 확인합니다.</p>
</div> </div>
<div class="alert alert-warning" th:if="${runtimeError}"> <div class="alert alert-warning" th:if="${runtimeError}">
<strong th:text="${runtimeError.title()}">조회할 수 없습니다.</strong> <strong th:text="${runtimeError.title()}">조회할 수 없습니다.</strong>
<span th:text="${runtimeError.message()}">message</span> <span th:text="${runtimeError.message()}">message</span>
</div> </div>
<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">
<div class="section-heading">
<h2>Policy 등록</h2>
</div>
<form method="post" action="/vpd-policies" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
VPD 적용 대상
<select class="form-select" name="objectKey" required>
<option th:each="object : ${objects}"
th:value="${object.owner() + '.' + object.objectName()}"
th:text="${object.displayName() + ' / ' + object.ordsPath()}"></option>
</select>
</label>
<label>
Policy 이름
<input class="form-control" name="policyName" placeholder="예: BOARD_POSTS_POLICY" required>
</label>
<label>
Function Owner
<input class="form-control" name="functionOwner" placeholder="비우면 현재 연결 사용자">
</label>
<label>
Function 이름
<input class="form-control" name="functionName" placeholder="비우면 POLICY_NAME_FILTER 자동 생성">
</label>
<label>
Statement Types
<input class="form-control" name="statementTypes" value="SELECT" placeholder="SELECT,INSERT,UPDATE,DELETE">
</label>
<div class="form-check align-self-end">
<input class="form-check-input" id="vpd-enabled" type="checkbox" name="enabled" value="true" checked>
<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">
<label class="form-check-label" for="vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<label class="span-2">
Filter predicate
<textarea class="form-control" id="vpd-filter-predicate" name="filterPredicate" rows="4"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')&#10;비우면 기존 Function 이름으로 ADD_POLICY만 실행합니다."></textarea>
</label>
<div class="question-presets span-2" aria-label="Filter predicate 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
data-target="vpd-filter-predicate"
data-question="1=0">
전체 차단
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-target="vpd-filter-predicate"
data-question="1=1">
전체 허용
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-target="vpd-filter-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="vpd-filter-predicate"
data-question="owner_emp_no = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')">
본인 소유
</button>
</div>
<button class="btn rw-btn-primary" type="submit">Policy 등록</button>
</form>
</section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
@@ -106,5 +177,17 @@
</div> </div>
</section> </section>
</main> </main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (!target) {
return;
}
target.value = button.dataset.question || '';
target.focus();
});
});
</script>
</body> </body>
</html> </html>