fix #424: add VPD policy registration form
This commit is contained in:
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'') 비우면 기존 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user