fix #424: add schema bulk VPD policy apply

This commit is contained in:
devmrko
2026-06-25 17:16:15 +09:00
parent 6957dc05b9
commit f98729aa78
8 changed files with 330 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdBulkApplyResult(
int total,
int created,
int skipped,
int failed
) {
public String summary() {
return "VPD bulk 적용 완료: 대상 " + total + "개, 등록 " + created
+ "개, 건너뜀 " + skipped + "개, 실패 " + failed + "";
}
}

View File

@@ -4,6 +4,7 @@ import java.util.List;
public record VpdPolicyFormOptions( public record VpdPolicyFormOptions(
List<String> policyNames, List<String> policyNames,
List<String> schemaOwners,
List<String> owners, List<String> owners,
List<VpdFunctionOption> functions, List<VpdFunctionOption> functions,
List<String> statementTypes List<String> statementTypes

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.domain.vpd;
public record VpdSchemaObjectOption(
String owner,
String objectName,
String objectType
) {
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.mapper; 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.VpdPolicyView; import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
@@ -13,16 +14,30 @@ public interface VpdPolicyMapper {
List<String> findPolicyNameOptions(); List<String> findPolicyNameOptions();
List<String> findSchemaOwnerOptions();
List<String> findOwnerOptions(); List<String> findOwnerOptions();
List<VpdFunctionOption> findFunctionOptions(); List<VpdFunctionOption> findFunctionOptions();
List<VpdSchemaObjectOption> findSchemaObjects(
@Param("owner") String owner,
@Param("includeTablesYn") String includeTablesYn,
@Param("includeViewsYn") String includeViewsYn
);
VpdPolicyView findPolicy( VpdPolicyView findPolicy(
@Param("objectOwner") String objectOwner, @Param("objectOwner") String objectOwner,
@Param("objectName") String objectName, @Param("objectName") String objectName,
@Param("policyName") String policyName @Param("policyName") String policyName
); );
VpdPolicyView findAnyPolicy(
@Param("objectOwner") String objectOwner,
@Param("objectName") String objectName,
@Param("policyName") String policyName
);
String findFunctionSource( String findFunctionSource(
@Param("owner") String owner, @Param("owner") String owner,
@Param("objectName") String objectName, @Param("objectName") String objectName,

View File

@@ -1,15 +1,18 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdBulkApplyResult;
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.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.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.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 java.util.Set;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -36,6 +39,7 @@ public class VpdPolicyService {
public VpdPolicyFormOptions formOptions() { public VpdPolicyFormOptions formOptions() {
return new VpdPolicyFormOptions( return new VpdPolicyFormOptions(
mapper.findPolicyNameOptions(), mapper.findPolicyNameOptions(),
mapper.findSchemaOwnerOptions(),
mapper.findOwnerOptions(), mapper.findOwnerOptions(),
mapper.findFunctionOptions(), mapper.findFunctionOptions(),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX") List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
@@ -47,10 +51,97 @@ public class VpdPolicyService {
List.of(), List.of(),
List.of(), List.of(),
List.of(), List.of(),
List.of(),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX") List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
); );
} }
public VpdBulkApplyResult bulkApplySchema(
String schemaOwner,
boolean includeTables,
boolean includeViews,
String functionKey,
String functionOwnerValue,
String functionNameValue,
String statementTypesValue,
boolean enabled,
boolean updateCheck,
String filterPredicateValue
) {
String owner = requiredIdentifier(schemaOwner, "Schema");
if (!includeTables && !includeViews) {
throw new AppException("TABLE 또는 VIEW 중 하나 이상 선택해야 합니다.");
}
List<VpdSchemaObjectOption> targets = mapper.findSchemaObjects(
owner,
includeTables ? "Y" : "N",
includeViews ? "Y" : "N"
);
if (targets.isEmpty()) {
throw new AppException("선택한 스키마에서 VPD 적용 대상 TABLE/VIEW를 찾을 수 없습니다: " + owner);
}
FunctionRef functionRef = parseFunctionRef(functionKey);
String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim();
if (functionRef == null && filterPredicate.isBlank()) {
throw new AppException("벌크 적용은 기존 Function을 선택하거나 Filter predicate를 입력해야 합니다.");
}
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
String functionOwner;
String packageName;
String functionName;
if (functionRef != null) {
functionOwner = functionRef.owner();
packageName = functionRef.packageName();
functionName = functionRef.functionName();
} else {
functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
? currentUser
: requiredIdentifier(functionOwnerValue, "Function owner");
packageName = null;
functionName = functionNameValue == null || functionNameValue.isBlank()
? generatedFunctionName(owner + "_BULK_POLICY")
: requiredIdentifier(functionNameValue, "Function name");
}
if (!filterPredicate.isBlank()) {
if (currentUser == null || !currentUser.equalsIgnoreCase(functionOwner)) {
throw new AppException("필터 함수 자동 생성은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
+ currentUser + ", Function owner: " + functionOwner);
}
createFilterFunction(functionName, filterPredicate);
}
String statementTypes = normalizeStatementTypes(statementTypesValue);
int created = 0;
int skipped = 0;
int failed = 0;
for (VpdSchemaObjectOption target : targets) {
String policyName = generatedPolicyName(target.objectName());
if (mapper.findAnyPolicy(target.owner(), target.objectName(), policyName) != null) {
skipped++;
continue;
}
try {
addPolicy(
target.owner(),
target.objectName(),
policyName,
functionOwner,
packageName == null ? functionName : packageName + "." + functionName,
statementTypes,
enabled,
updateCheck
);
created++;
} catch (DataAccessException exception) {
failed++;
}
}
return new VpdBulkApplyResult(targets.size(), created, skipped, failed);
}
public VpdFunctionSource findFunctionSource(String owner, String packageName, String functionName) { public VpdFunctionSource findFunctionSource(String owner, String packageName, String functionName) {
String normalizedOwner = requiredIdentifier(owner, "Function owner"); String normalizedOwner = requiredIdentifier(owner, "Function owner");
String normalizedFunction = requiredIdentifier(functionName, "Function name"); String normalizedFunction = requiredIdentifier(functionName, "Function name");
@@ -106,6 +197,28 @@ public class VpdPolicyService {
createFilterFunction(functionName, filterPredicate); createFilterFunction(functionName, filterPredicate);
} }
addPolicy(
objectOwner,
objectName,
policyName,
functionOwner,
packageName == null ? functionName : packageName + "." + functionName,
statementTypes,
command.enabled(),
command.updateCheck()
);
}
private void addPolicy(
String objectOwner,
String objectName,
String policyName,
String functionOwner,
String policyFunction,
String statementTypes,
boolean enabled,
boolean updateCheck
) {
jdbcTemplate.update(""" jdbcTemplate.update("""
BEGIN BEGIN
DBMS_RLS.ADD_POLICY( DBMS_RLS.ADD_POLICY(
@@ -120,12 +233,12 @@ public class VpdPolicyService {
policy_type => DBMS_RLS.DYNAMIC policy_type => DBMS_RLS.DYNAMIC
); );
END; END;
""".formatted(command.updateCheck() ? "TRUE" : "FALSE", command.enabled() ? "TRUE" : "FALSE"), """.formatted(updateCheck ? "TRUE" : "FALSE", enabled ? "TRUE" : "FALSE"),
objectOwner, objectOwner,
objectName, objectName,
policyName, policyName,
functionOwner, functionOwner,
packageName == null ? functionName : packageName + "." + functionName, policyFunction,
statementTypes); statementTypes);
} }
@@ -301,6 +414,11 @@ public class VpdPolicyService {
return generated.length() > 128 ? generated.substring(0, 128) : generated; return generated.length() > 128 ? generated.substring(0, 128) : generated;
} }
private String generatedPolicyName(String objectName) {
String name = objectName + "_POLICY";
return name.length() > 128 ? name.substring(0, 128) : name;
}
private FunctionRef parseFunctionRef(String functionKey) { private FunctionRef parseFunctionRef(String functionKey) {
if (functionKey == null || functionKey.isBlank()) { if (functionKey == null || functionKey.isBlank()) {
return null; return null;

View File

@@ -80,6 +80,43 @@ public class VpdPolicyController {
return "redirect:/vpd-policies"; return "redirect:/vpd-policies";
} }
@PostMapping("/vpd-policies/bulk")
public String bulkApplyPolicy(
@RequestParam String schemaOwner,
@RequestParam(defaultValue = "false") boolean includeTables,
@RequestParam(defaultValue = "false") boolean includeViews,
@RequestParam(required = false) String functionKey,
@RequestParam(required = false) String functionOwner,
@RequestParam(required = false) String functionName,
@RequestParam(defaultValue = "SELECT") List<String> statementTypes,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam(defaultValue = "false") boolean updateCheck,
@RequestParam(required = false) String filterPredicate,
RedirectAttributes redirectAttributes
) {
try {
var result = vpdPolicyService.bulkApplySchema(
schemaOwner,
includeTables,
includeViews,
functionKey,
functionOwner,
functionName,
String.join(",", statementTypes),
enabled,
updateCheck,
filterPredicate
);
redirectAttributes.addFlashAttribute("successMessage", result.summary());
} 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

@@ -56,6 +56,20 @@
ORDER BY 1 ORDER BY 1
</select> </select>
<select id="findSchemaOwnerOptions" resultType="string">
SELECT DISTINCT o.owner
FROM all_objects o
JOIN all_users u ON u.username = o.owner
WHERE o.object_type IN ('TABLE', 'VIEW')
AND NVL(u.oracle_maintained, 'N') = 'N'
AND o.owner NOT IN ('SYS', 'SYSTEM', 'ORDS_METADATA', 'ORDS_PUBLIC_USER')
AND o.owner NOT LIKE 'APEX\_%' ESCAPE '\'
AND o.owner NOT LIKE 'C##%'
AND o.object_name NOT LIKE 'BIN$%'
ORDER BY o.owner
FETCH FIRST 200 ROWS ONLY
</select>
<select id="findOwnerOptions" resultType="string"> <select id="findOwnerOptions" resultType="string">
SELECT USER FROM dual SELECT USER FROM dual
UNION UNION
@@ -127,6 +141,18 @@
FETCH FIRST 300 ROWS ONLY FETCH FIRST 300 ROWS ONLY
</select> </select>
<select id="findSchemaObjects" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption">
SELECT owner, object_name, object_type
FROM all_objects
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
AND object_name NOT LIKE 'BIN$%'
AND (
(#{includeTablesYn,jdbcType=VARCHAR} = 'Y' AND object_type = 'TABLE')
OR (#{includeViewsYn,jdbcType=VARCHAR} = 'Y' AND object_type = 'VIEW')
)
ORDER BY object_type, object_name
</select>
<select id="findPolicy" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView"> <select id="findPolicy" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView">
SELECT <include refid="policyColumns"><property name="alias" value="p."/></include> SELECT <include refid="policyColumns"><property name="alias" value="p."/></include>
FROM all_policies p FROM all_policies p
@@ -142,6 +168,14 @@
) )
</select> </select>
<select id="findAnyPolicy" resultType="com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView">
SELECT <include refid="policyColumns"><property name="alias" value="p."/></include>
FROM all_policies p
WHERE p.object_owner = UPPER(#{objectOwner,jdbcType=VARCHAR})
AND p.object_name = UPPER(#{objectName,jdbcType=VARCHAR})
AND p.policy_name = UPPER(#{policyName,jdbcType=VARCHAR})
</select>
<select id="findFunctionSource" resultType="string"> <select id="findFunctionSource" resultType="string">
SELECT LISTAGG(text, '') WITHIN GROUP (ORDER BY line) SELECT LISTAGG(text, '') WITHIN GROUP (ORDER BY line)
FROM all_source FROM all_source

View File

@@ -117,6 +117,107 @@
</form> </form>
</section> </section>
<section class="content-band">
<div class="section-heading">
<h2>스키마 벌크 적용</h2>
</div>
<form method="post" action="/vpd-policies/bulk" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
Schema
<select class="form-select" name="schemaOwner" required>
<option th:each="owner : ${formOptions.schemaOwners()}"
th:value="${owner}"
th:text="${owner}"></option>
</select>
</label>
<div>
Object Types
<div class="checkbox-row">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="includeTables" value="true" checked>
<span class="form-check-label">TABLE</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="includeViews" value="true" checked>
<span class="form-check-label">VIEW</span>
</label>
</div>
</div>
<label class="span-2">
기존 Function 선택
<select class="form-select" name="functionKey">
<option value="">Filter predicate로 벌크 function 자동 생성</option>
<option th:each="function : ${formOptions.functions()}"
th:value="${function.value()}"
th:text="${function.label()}"></option>
</select>
</label>
<label>
Function Owner
<select class="form-select" name="functionOwner">
<option value="">현재 연결 사용자</option>
<option th:each="owner : ${formOptions.owners()}"
th:value="${owner}"
th:text="${owner}"></option>
</select>
</label>
<label>
Function 이름
<select class="form-select" name="functionName">
<option value="">SCHEMA_BULK_FILTER 자동 생성</option>
<option th:each="function : ${formOptions.functions()}"
th:value="${function.functionName()}"
th:text="${function.functionName()}"></option>
</select>
</label>
<div>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input"
type="checkbox"
name="statementTypes"
th:value="${statement}"
th:checked="${statement == 'SELECT'}">
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="vpd-bulk-enabled" type="checkbox" name="enabled" value="true" checked>
<label class="form-check-label" for="vpd-bulk-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="vpd-bulk-update-check" type="checkbox" name="updateCheck" value="true">
<label class="form-check-label" for="vpd-bulk-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<label class="span-2">
Filter predicate
<textarea class="form-control" id="vpd-bulk-filter-predicate" name="filterPredicate" rows="4"
placeholder="기존 Function을 선택하지 않으면 이 predicate로 스키마 공통 function을 생성합니다."></textarea>
</label>
<div class="question-presets span-2" aria-label="Bulk filter predicate 예시">
<button class="btn rw-btn-secondary question-preset" type="button"
data-target="vpd-bulk-filter-predicate"
data-question="1=0">
전체 차단
</button>
<button class="btn rw-btn-secondary question-preset" type="button"
data-target="vpd-bulk-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-bulk-filter-predicate"
data-question="owner_emp_no = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')">
본인 소유
</button>
</div>
<button class="btn rw-btn-primary" type="submit">스키마 전체 적용</button>
</form>
</section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>VPD Policies</h2> <h2>VPD Policies</h2>