833 lines
33 KiB
Java
833 lines
33 KiB
Java
package com.cloudhandson.vpdbackoffice.service;
|
|
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdBulkApplyResult;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdDescriptionNote;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionSource;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdObjectFilterDetail;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyCreateCommand;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyDetail;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyExplanation;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyTemplateOption;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyView;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdSchemaObjectOption;
|
|
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdTargetView;
|
|
import com.cloudhandson.vpdbackoffice.mapper.VpdPolicyMapper;
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.concurrent.atomic.AtomicReference;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
import org.springframework.dao.DataAccessException;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
@Service
|
|
public class VpdPolicyService {
|
|
|
|
private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
|
|
// ALL_* dictionary views are comparatively expensive in Autonomous Database.
|
|
// Mutating VPD operations call clearCatalogCache(), so a longer read cache does
|
|
// not delay an administrator's own changes from appearing in the UI.
|
|
private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L;
|
|
private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY";
|
|
private static final String DEFAULT_PERMISSION_FILTER_FUNCTION = "CB_AGENT_DOC_VPD_FILTER";
|
|
/*
|
|
* DBMS_RLS.ADD_POLICY accepts PL/SQL BOOLEAN arguments. Keep all four
|
|
* permitted flag combinations as fixed statements: database object names
|
|
* and function references are JDBC bind values, and no request value is
|
|
* ever interpolated into executable SQL or PL/SQL source.
|
|
*/
|
|
private static final String ADD_POLICY_ENABLED_WITH_UPDATE_CHECK = """
|
|
BEGIN
|
|
DBMS_RLS.ADD_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
function_schema => ?, policy_function => ?, statement_types => ?,
|
|
update_check => TRUE, enable => TRUE, policy_type => DBMS_RLS.DYNAMIC
|
|
);
|
|
END;
|
|
""";
|
|
private static final String ADD_POLICY_ENABLED_WITHOUT_UPDATE_CHECK = """
|
|
BEGIN
|
|
DBMS_RLS.ADD_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
function_schema => ?, policy_function => ?, statement_types => ?,
|
|
update_check => FALSE, enable => TRUE, policy_type => DBMS_RLS.DYNAMIC
|
|
);
|
|
END;
|
|
""";
|
|
private static final String ADD_POLICY_DISABLED_WITH_UPDATE_CHECK = """
|
|
BEGIN
|
|
DBMS_RLS.ADD_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
function_schema => ?, policy_function => ?, statement_types => ?,
|
|
update_check => TRUE, enable => FALSE, policy_type => DBMS_RLS.DYNAMIC
|
|
);
|
|
END;
|
|
""";
|
|
private static final String ADD_POLICY_DISABLED_WITHOUT_UPDATE_CHECK = """
|
|
BEGIN
|
|
DBMS_RLS.ADD_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
function_schema => ?, policy_function => ?, statement_types => ?,
|
|
update_check => FALSE, enable => FALSE, policy_type => DBMS_RLS.DYNAMIC
|
|
);
|
|
END;
|
|
""";
|
|
private static final Pattern RETURN_LITERAL = Pattern.compile(
|
|
"(?is)\\bRETURN\\s+'((?:''|[^'])*)'\\s*;"
|
|
);
|
|
|
|
private final VpdPolicyMapper mapper;
|
|
private final JdbcTemplate jdbcTemplate;
|
|
private final OpenAiCompatibleClient aiClient;
|
|
private final Map<String, CacheEntry<List<VpdTargetView>>> vpdTargetsCache = new ConcurrentHashMap<>();
|
|
private final AtomicReference<CacheEntry<VpdPolicyFormOptions>> formOptionsCache =
|
|
new AtomicReference<>();
|
|
|
|
public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) {
|
|
this.mapper = mapper;
|
|
this.jdbcTemplate = jdbcTemplate;
|
|
this.aiClient = aiClient;
|
|
}
|
|
|
|
public List<VpdPolicyView> findPolicies() {
|
|
return mapper.findPolicies();
|
|
}
|
|
|
|
public List<VpdTargetView> findVpdTargets() {
|
|
return findVpdTargets(null);
|
|
}
|
|
|
|
public List<VpdTargetView> findVpdTargets(String owner) {
|
|
String normalizedOwner = normalizeOptionalIdentifier(owner);
|
|
String cacheKey = normalizedOwner == null ? "__MANAGED__" : normalizedOwner;
|
|
CacheEntry<List<VpdTargetView>> cached = vpdTargetsCache.get(cacheKey);
|
|
if (cached != null && !cached.expired()) {
|
|
return cached.value();
|
|
}
|
|
List<VpdTargetView> targets = List.copyOf(mapper.findVpdTargets(normalizedOwner));
|
|
vpdTargetsCache.put(cacheKey, new CacheEntry<>(targets, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
|
|
return targets;
|
|
}
|
|
|
|
public VpdPolicyFormOptions formOptions() {
|
|
CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache.get();
|
|
if (cached != null && !cached.expired()) {
|
|
return cached.value();
|
|
}
|
|
List<VpdFunctionOption> functions = mapper.findFunctionOptions();
|
|
VpdPolicyFormOptions options = new VpdPolicyFormOptions(
|
|
mapper.findPolicyNameOptions(),
|
|
mapper.findSchemaOwnerOptions(),
|
|
mapper.findOwnerOptions(),
|
|
functions,
|
|
buildPolicyTemplateOptions(functions, mapper.findPolicyTemplateOptions()),
|
|
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
|
|
);
|
|
formOptionsCache.set(new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
|
|
return options;
|
|
}
|
|
|
|
public VpdPolicyFormOptions emptyFormOptions() {
|
|
return new VpdPolicyFormOptions(
|
|
List.of(),
|
|
List.of(),
|
|
List.of(),
|
|
List.of(),
|
|
List.of(),
|
|
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
|
|
);
|
|
}
|
|
|
|
public String currentUser() {
|
|
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
|
|
}
|
|
|
|
@Transactional
|
|
public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) {
|
|
String functionName = requiredIdentifier(functionNameValue, "Function name");
|
|
if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) {
|
|
throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION
|
|
+ "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·행 접근 규칙 화면에서 변경하세요.");
|
|
}
|
|
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
|
|
String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
|
|
? currentUser
|
|
: requiredIdentifier(functionOwnerValue, "Function owner");
|
|
if (currentUser == null || !currentUser.equalsIgnoreCase(functionOwner)) {
|
|
throw new AppException("Filter function 등록/수정은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
|
|
+ currentUser + ", Function owner: " + functionOwner);
|
|
}
|
|
String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim();
|
|
if (filterPredicate.isBlank()) {
|
|
throw new AppException("Filter predicate는 필수입니다.");
|
|
}
|
|
createFilterFunction(functionName, filterPredicate);
|
|
clearCatalogCache();
|
|
}
|
|
|
|
@Transactional
|
|
public void createDefaultPermissionPolicy(String objectKey) {
|
|
String[] objectParts = objectKey == null ? new String[0] : objectKey.split("\\.", 2);
|
|
if (objectParts.length != 2) {
|
|
throw new AppException("보호할 객체 형식이 올바르지 않습니다: " + objectKey);
|
|
}
|
|
String functionKey = formOptions().defaultPermissionFunctionKey();
|
|
if (functionKey.isBlank()) {
|
|
throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION
|
|
+ "가 설치되어 있지 않습니다. 운영 상태에서 동적 권한 필터 설치 여부를 확인한 뒤 다시 적용하세요.");
|
|
}
|
|
createPolicy(new VpdPolicyCreateCommand(
|
|
objectParts[0],
|
|
objectParts[1],
|
|
COMMON_POLICY_NAME,
|
|
functionKey,
|
|
null,
|
|
null,
|
|
"SELECT",
|
|
true,
|
|
false,
|
|
null
|
|
));
|
|
}
|
|
|
|
@Transactional
|
|
public void replacePolicy(String oldObjectKey, String oldPolicyName, VpdPolicyCreateCommand command) {
|
|
String[] objectParts = oldObjectKey == null ? new String[0] : oldObjectKey.split("\\.", 2);
|
|
if (objectParts.length != 2) {
|
|
throw new AppException("수정할 Policy 대상 형식이 올바르지 않습니다: " + oldObjectKey);
|
|
}
|
|
String objectOwner = requiredIdentifier(objectParts[0], "Object owner");
|
|
String objectName = requiredIdentifier(objectParts[1], "Object name");
|
|
String policyName = requiredIdentifier(oldPolicyName, "Policy name");
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_RLS.DROP_POLICY(
|
|
object_schema => ?,
|
|
object_name => ?,
|
|
policy_name => ?
|
|
);
|
|
END;
|
|
""", objectOwner, objectName, policyName);
|
|
createPolicy(command);
|
|
clearCatalogCache();
|
|
}
|
|
|
|
public VpdBulkApplyResult bulkApplySchema(
|
|
String schemaOwner,
|
|
boolean includeTables,
|
|
boolean includeViews,
|
|
String policyNameValue,
|
|
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);
|
|
}
|
|
|
|
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()) {
|
|
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) {
|
|
if (mapper.findAnyPolicy(target.owner(), target.objectName(), bulkPolicyName) != null) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
try {
|
|
addPolicy(
|
|
target.owner(),
|
|
target.objectName(),
|
|
bulkPolicyName,
|
|
functionOwner,
|
|
packageName == null ? functionName : packageName + "." + functionName,
|
|
statementTypes,
|
|
enabled,
|
|
updateCheck
|
|
);
|
|
created++;
|
|
} catch (DataAccessException exception) {
|
|
failed++;
|
|
}
|
|
}
|
|
clearCatalogCache();
|
|
return new VpdBulkApplyResult(targets.size(), created, skipped, failed);
|
|
}
|
|
|
|
public VpdFunctionSource findFunctionSource(String owner, String packageName, String functionName) {
|
|
String normalizedOwner = requiredIdentifier(owner, "Function owner");
|
|
String normalizedFunction = requiredIdentifier(functionName, "Function name");
|
|
String normalizedPackage = normalizeOptionalIdentifier(packageName);
|
|
String objectName = normalizedPackage == null ? normalizedFunction : normalizedPackage;
|
|
String objectType = normalizedPackage == null ? "FUNCTION" : "PACKAGE BODY";
|
|
String source = mapper.findFunctionSource(normalizedOwner, objectName, objectType);
|
|
return new VpdFunctionSource(normalizedOwner, objectName, objectType, source);
|
|
}
|
|
|
|
public VpdPolicyDetail findPolicyDetail(String objectOwner, String objectName, String policyName) {
|
|
String normalizedOwner = requiredIdentifier(objectOwner, "Object owner");
|
|
String normalizedObject = requiredIdentifier(objectName, "Object name");
|
|
String normalizedPolicy = requiredIdentifier(policyName, "Policy name");
|
|
VpdPolicyView policy = mapper.findPolicy(normalizedOwner, normalizedObject, normalizedPolicy);
|
|
if (policy == null) {
|
|
throw new AppException("VPD policy를 찾을 수 없습니다.");
|
|
}
|
|
return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy));
|
|
}
|
|
|
|
public String findPolicyDescription(String objectOwner, String objectName, String policyName) {
|
|
String description;
|
|
try {
|
|
description = mapper.findPolicyDescription(objectOwner, objectName, policyName);
|
|
} catch (DataAccessException ignored) {
|
|
description = null;
|
|
}
|
|
return description == null || description.isBlank()
|
|
? objectOwner + "." + objectName + "에 요청마다 현재 행 접근 규칙의 조건을 적용하는 " + policyName + " policy입니다."
|
|
: description;
|
|
}
|
|
|
|
public String findFilterDescription(String functionOwner, String functionName) {
|
|
String description;
|
|
try {
|
|
description = mapper.findFilterDescription(functionOwner, functionName);
|
|
} catch (DataAccessException ignored) {
|
|
description = null;
|
|
}
|
|
return description == null || description.isBlank()
|
|
? (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)
|
|
? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."
|
|
: "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.")
|
|
: description;
|
|
}
|
|
|
|
/**
|
|
* Retrieves all UI descriptions in one round trip. The policy screen used to
|
|
* issue one ADB query for every displayed policy and filter.
|
|
*/
|
|
public Map<String, String> findPolicyDescriptionMap() {
|
|
return descriptionMap(mapper.findPolicyDescriptions());
|
|
}
|
|
|
|
public Map<String, String> findFilterDescriptionMap() {
|
|
return descriptionMap(mapper.findFilterDescriptions());
|
|
}
|
|
|
|
/**
|
|
* Returns the literal predicate used by a simple standalone Filter function created by this UI.
|
|
* Packaged or system-managed functions intentionally return an empty string because their
|
|
* source is not a single editable literal.
|
|
*/
|
|
public String findFilterPredicate(String owner, String packageName, String functionName) {
|
|
if (packageName != null && !packageName.isBlank()
|
|
|| DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) {
|
|
return "";
|
|
}
|
|
try {
|
|
VpdFunctionSource source = findFunctionSource(owner, null, functionName);
|
|
if (source.source() == null) {
|
|
return "";
|
|
}
|
|
Matcher matcher = RETURN_LITERAL.matcher(source.source());
|
|
return matcher.find() ? matcher.group(1).replace("''", "'") : "";
|
|
} catch (DataAccessException | AppException ignored) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
@Transactional
|
|
public void savePolicyDescription(String objectOwner, String objectName, String policyName, String description) {
|
|
String normalized = normalizeDescription(description);
|
|
mapper.upsertPolicyDescription(
|
|
requiredIdentifier(objectOwner, "Object owner"),
|
|
requiredIdentifier(objectName, "Object name"),
|
|
requiredIdentifier(policyName, "Policy name"),
|
|
normalized
|
|
);
|
|
}
|
|
|
|
@Transactional
|
|
public void saveFilterDescription(String functionOwner, String functionName, String description) {
|
|
String normalized = normalizeDescription(description);
|
|
mapper.upsertFilterDescription(
|
|
requiredIdentifier(functionOwner, "Function owner"),
|
|
requiredIdentifier(functionName, "Function name"),
|
|
normalized
|
|
);
|
|
}
|
|
|
|
public VpdObjectFilterDetail findObjectFilterDetail(String objectOwner, String objectName) {
|
|
String normalizedOwner = requiredIdentifier(objectOwner, "Object owner");
|
|
String normalizedObject = requiredIdentifier(objectName, "Object name");
|
|
List<VpdObjectFilterDetail.Row> rows = mapper.findPoliciesForObject(normalizedOwner, normalizedObject).stream()
|
|
.map(policy -> new VpdObjectFilterDetail.Row(
|
|
policy,
|
|
findFunctionSource(policy.functionOwner(), policy.packageName(), policy.functionName())
|
|
))
|
|
.toList();
|
|
return new VpdObjectFilterDetail(normalizedOwner, normalizedObject, rows);
|
|
}
|
|
|
|
@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);
|
|
FunctionRef functionRef = parseFunctionRef(defaultFunctionKey(command.functionKey()));
|
|
String functionOwner;
|
|
String packageName;
|
|
String functionName;
|
|
if (functionRef != null) {
|
|
functionOwner = functionRef.owner();
|
|
packageName = functionRef.packageName();
|
|
functionName = functionRef.functionName();
|
|
} else {
|
|
functionOwner = command.functionOwner() == null || command.functionOwner().isBlank()
|
|
? currentUser
|
|
: requiredIdentifier(command.functionOwner(), "Function owner");
|
|
packageName = null;
|
|
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);
|
|
}
|
|
|
|
addPolicy(
|
|
objectOwner,
|
|
objectName,
|
|
policyName,
|
|
functionOwner,
|
|
packageName == null ? functionName : packageName + "." + functionName,
|
|
statementTypes,
|
|
command.enabled(),
|
|
command.updateCheck()
|
|
);
|
|
clearCatalogCache();
|
|
}
|
|
|
|
public void clearCatalogCache() {
|
|
vpdTargetsCache.clear();
|
|
formOptionsCache.set(null);
|
|
}
|
|
|
|
private Map<String, String> descriptionMap(List<VpdDescriptionNote> notes) {
|
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
|
for (VpdDescriptionNote note : notes) {
|
|
if (note.noteKey() != null && note.description() != null && !note.description().isBlank()) {
|
|
descriptions.put(note.noteKey(), note.description());
|
|
}
|
|
}
|
|
return descriptions;
|
|
}
|
|
|
|
private List<VpdPolicyTemplateOption> buildPolicyTemplateOptions(
|
|
List<VpdFunctionOption> functions,
|
|
List<VpdPolicyTemplateOption> existingTemplates
|
|
) {
|
|
List<VpdPolicyTemplateOption> templates = new ArrayList<>();
|
|
functions.stream()
|
|
.filter(function -> DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(function.functionName()))
|
|
.findFirst()
|
|
.ifPresent(function -> templates.add(new VpdPolicyTemplateOption(
|
|
COMMON_POLICY_NAME,
|
|
function.owner(),
|
|
function.packageName(),
|
|
function.functionName(),
|
|
"SELECT",
|
|
"YES",
|
|
"NO"
|
|
)));
|
|
templates.addAll(existingTemplates);
|
|
|
|
Map<String, VpdPolicyTemplateOption> uniqueTemplates = new LinkedHashMap<>();
|
|
for (VpdPolicyTemplateOption template : templates) {
|
|
uniqueTemplates.putIfAbsent(template.label().toUpperCase(Locale.ROOT), template);
|
|
}
|
|
return List.copyOf(uniqueTemplates.values());
|
|
}
|
|
|
|
private void addPolicy(
|
|
String objectOwner,
|
|
String objectName,
|
|
String policyName,
|
|
String functionOwner,
|
|
String policyFunction,
|
|
String statementTypes,
|
|
boolean enabled,
|
|
boolean updateCheck
|
|
) {
|
|
jdbcTemplate.update(addPolicySql(enabled, updateCheck),
|
|
objectOwner,
|
|
objectName,
|
|
policyName,
|
|
functionOwner,
|
|
policyFunction,
|
|
statementTypes);
|
|
}
|
|
|
|
private String addPolicySql(boolean enabled, boolean updateCheck) {
|
|
if (enabled) {
|
|
return updateCheck
|
|
? ADD_POLICY_ENABLED_WITH_UPDATE_CHECK
|
|
: ADD_POLICY_ENABLED_WITHOUT_UPDATE_CHECK;
|
|
}
|
|
return updateCheck
|
|
? ADD_POLICY_DISABLED_WITH_UPDATE_CHECK
|
|
: ADD_POLICY_DISABLED_WITHOUT_UPDATE_CHECK;
|
|
}
|
|
|
|
public VpdPolicyExplanation explainPolicy(String objectOwner, String objectName, String policyName) {
|
|
VpdPolicyDetail detail = findPolicyDetail(objectOwner, objectName, policyName);
|
|
VpdPolicyView policy = detail.policy();
|
|
VpdFunctionSource functionSource = findFunctionSource(
|
|
policy.functionOwner(),
|
|
policy.packageName(),
|
|
policy.functionName()
|
|
);
|
|
String prompt = buildExplanationPrompt(detail, functionSource);
|
|
if (!aiClient.configured()) {
|
|
return new VpdPolicyExplanation(
|
|
"AI_NOT_CONFIGURED",
|
|
aiClient.modelName(),
|
|
"AI base URL/API Key 설정이 없어 모델 호출은 건너뛰었습니다. 아래 Prompt와 function source를 기준으로 policy/filter 내용을 확인하세요.",
|
|
prompt,
|
|
detail,
|
|
functionSource
|
|
);
|
|
}
|
|
try {
|
|
String answer = aiClient.chat(vpdSystemPrompt(), prompt);
|
|
return new VpdPolicyExplanation("SUCCESS", aiClient.modelName(), answer, prompt, detail, functionSource);
|
|
} catch (Exception e) {
|
|
return new VpdPolicyExplanation(
|
|
"AI_CALL_FAILED",
|
|
aiClient.modelName(),
|
|
"AI 호출은 실패했지만 policy/filter 근거는 수집했습니다. 상세: " + e.getMessage(),
|
|
prompt,
|
|
detail,
|
|
functionSource
|
|
);
|
|
}
|
|
}
|
|
|
|
private String buildExplanationPrompt(VpdPolicyDetail detail, VpdFunctionSource functionSource) {
|
|
VpdPolicyView policy = detail.policy();
|
|
return """
|
|
다음 Oracle VPD policy와 policy function source를 근거로 운영자가 바로 검토할 수 있는 분석 보고서를 작성한다.
|
|
일반적인 VPD 설명은 금지한다. 반드시 제공된 source에서 확인되는 변수, SELECT, IF/ELSIF, RETURN predicate만 근거로 설명한다.
|
|
|
|
[입력: Policy Metadata]
|
|
- Object: %s
|
|
- Policy Group: %s
|
|
- Policy Name: %s
|
|
- Function: %s
|
|
- Statement Types: %s
|
|
- Enabled: %s
|
|
- Policy Type: %s
|
|
- Check Option: %s
|
|
- Static Policy: %s
|
|
- Long Predicate: %s
|
|
|
|
[입력: DBMS_RLS.ADD_POLICY]
|
|
%s
|
|
|
|
[입력: Policy Function Source]
|
|
%s
|
|
|
|
[출력 형식]
|
|
## 요약
|
|
- 이 policy가 무엇을 허용/차단하는지 3줄 이내로 먼저 설명한다.
|
|
- fail-closed 조건이 있으면 요약에 포함한다.
|
|
- 컬럼 마스킹은 ASO/Data Redaction 별도 정책에서 판단한다. 이 VPD source만으로 알 수 있는 행 접근 범위와, 컬럼 마스킹 판단 가능 여부를 구분한다.
|
|
|
|
## 상세
|
|
|
|
### 1. 정책 적용 범위
|
|
- 대상 객체, 적용 SQL, enabled 여부를 한 문단으로 설명한다.
|
|
|
|
### 2. 필터 함수 입력과 조회 값
|
|
- source에서 읽어오는 context/user/role/permission/rule 값을 bullet로 정리한다.
|
|
- 각 항목 옆에 source 근거를 짧게 적는다. 예: "v_user_id: SYS_CONTEXT(...)"
|
|
|
|
### 3. Predicate 생성 분기
|
|
- source의 RETURN 값을 기준으로 Markdown 표를 만든다.
|
|
- 컬럼은 "조건", "반환 predicate", "의미", "보이는 행/차단되는 행"으로 한다.
|
|
- 표는 반드시 Markdown pipe table 형식으로 작성한다.
|
|
- '1=0' 같은 fail-closed predicate가 있으면 반드시 명시한다.
|
|
|
|
### 4. 실제 접근 결과 해석
|
|
- 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다.
|
|
- 컬럼 마스킹은 ASO/Data Redaction 별도 정책이다. source에 직접 있지 않으면 "이 VPD policy source만으로는 컬럼 마스킹 판단 불가"라고 쓴다.
|
|
|
|
### 5. 운영 확인 포인트
|
|
- 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다.
|
|
|
|
[엄격한 규칙]
|
|
- 한국어로 답변한다.
|
|
- Markdown 형식으로 답변한다.
|
|
- source에 없는 테이블, 컬럼, role 이름을 만들지 않는다.
|
|
- source 근거가 부족하면 추측하지 말고 "source만으로는 판단 불가"라고 쓴다.
|
|
- "일반적으로", "보통", "아마" 같은 표현을 쓰지 않는다.
|
|
- 답변은 80줄 이내로 유지한다.
|
|
""".formatted(
|
|
policy.objectDisplayName(),
|
|
policy.policyGroup(),
|
|
policy.policyName(),
|
|
policy.functionDisplayName(),
|
|
blankToDefault(policy.statementTypes(), "-"),
|
|
policy.enabled(),
|
|
policy.policyType(),
|
|
policy.checkOption(),
|
|
policy.staticPolicy(),
|
|
policy.longPredicate(),
|
|
detail.ddl(),
|
|
functionSource.found() ? functionSource.source() : "ALL_SOURCE에서 조회 가능한 source가 없습니다."
|
|
);
|
|
}
|
|
|
|
private String vpdSystemPrompt() {
|
|
return """
|
|
당신은 Oracle ADB VPD(DBMS_RLS) policy function을 리뷰하는 데이터 보안 엔지니어입니다.
|
|
목표는 운영자가 실제 filter predicate 동작을 검증할 수 있게 source 기반으로 설명하는 것입니다.
|
|
제공된 policy metadata와 source만 근거로 판단하고, source에 없는 업무 규칙이나 테이블 의미를 추측하지 마세요.
|
|
가능하면 source의 변수명, SELECT 대상, RETURN predicate를 그대로 언급하세요.
|
|
""";
|
|
}
|
|
|
|
private String buildAddPolicyBlock(VpdPolicyView policy) {
|
|
return """
|
|
BEGIN
|
|
DBMS_RLS.ADD_POLICY(
|
|
object_schema => '%s',
|
|
object_name => '%s',
|
|
policy_name => '%s',
|
|
function_schema => '%s',
|
|
policy_function => '%s',
|
|
statement_types => '%s',
|
|
update_check => %s,
|
|
enable => %s,
|
|
static_policy => %s,
|
|
policy_type => %s,
|
|
long_predicate => %s
|
|
);
|
|
END;
|
|
/
|
|
""".formatted(
|
|
policy.objectOwner(),
|
|
policy.objectName(),
|
|
policy.policyName(),
|
|
policy.functionOwner(),
|
|
policyFunctionArgument(policy),
|
|
blankToDefault(policy.statementTypes(), "SELECT"),
|
|
yesNoBoolean(policy.checkOption()),
|
|
yesNoBoolean(policy.enabled()),
|
|
yesNoBoolean(policy.staticPolicy()),
|
|
policyTypeArgument(policy.policyType()),
|
|
yesNoBoolean(policy.longPredicate())
|
|
);
|
|
}
|
|
|
|
private void createFilterFunction(String functionName, String filterPredicate) {
|
|
/*
|
|
* Oracle DDL cannot bind an object identifier or a function body. This
|
|
* is therefore deliberately the sole dynamic-DDL boundary in this
|
|
* service. functionName passed here has already gone through
|
|
* requiredIdentifier() ([A-Z][A-Z0-9_$#]{0,127}); filterPredicate is put
|
|
* inside one SQL literal after every quote is doubled. Those two checks
|
|
* prevent a caller from terminating the statement or adding DDL.
|
|
*/
|
|
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 FunctionRef parseFunctionRef(String functionKey) {
|
|
if (functionKey == null || functionKey.isBlank()) {
|
|
return null;
|
|
}
|
|
String[] parts = functionKey.trim().toUpperCase(Locale.ROOT).split("\\.");
|
|
if (parts.length == 2) {
|
|
return new FunctionRef(requiredIdentifier(parts[0], "Function owner"), null,
|
|
requiredIdentifier(parts[1], "Function name"));
|
|
}
|
|
if (parts.length == 3) {
|
|
return new FunctionRef(requiredIdentifier(parts[0], "Function owner"),
|
|
requiredIdentifier(parts[1], "Package name"), requiredIdentifier(parts[2], "Function name"));
|
|
}
|
|
throw new AppException("Function 선택 값이 올바르지 않습니다: " + functionKey);
|
|
}
|
|
|
|
private String defaultFunctionKey(String functionKey) {
|
|
if (functionKey != null && !functionKey.isBlank()) {
|
|
return functionKey;
|
|
}
|
|
return formOptions().defaultPermissionFunctionKey();
|
|
}
|
|
|
|
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 normalizeDescription(String value) {
|
|
String normalized = value == null ? "" : value.trim();
|
|
if (normalized.isBlank()) {
|
|
throw new AppException("정책/필터 설명은 필수입니다.");
|
|
}
|
|
if (normalized.length() > 500) {
|
|
throw new AppException("정책/필터 설명은 500자 이내여야 합니다.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private String escapeSqlLiteral(String value) {
|
|
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 String policyFunctionArgument(VpdPolicyView policy) {
|
|
if (policy.packageName() == null || policy.packageName().isBlank()) {
|
|
return policy.functionName();
|
|
}
|
|
return policy.packageName() + "." + policy.functionName();
|
|
}
|
|
|
|
private String blankToDefault(String value, String defaultValue) {
|
|
return value == null || value.isBlank() ? defaultValue : value;
|
|
}
|
|
|
|
private String yesNoBoolean(String value) {
|
|
return "YES".equalsIgnoreCase(value) ? "TRUE" : "FALSE";
|
|
}
|
|
|
|
private String policyTypeArgument(String policyType) {
|
|
if (policyType == null || policyType.isBlank()) {
|
|
return "NULL";
|
|
}
|
|
return "DBMS_RLS." + policyType.trim().toUpperCase(Locale.ROOT);
|
|
}
|
|
|
|
private String requiredIdentifier(String value, String label) {
|
|
String normalized = normalizeOptionalIdentifier(value);
|
|
if (normalized == null) {
|
|
throw new AppException(label + " 값이 없습니다.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private String normalizeOptionalIdentifier(String value) {
|
|
if (value == null || value.isBlank()) {
|
|
return null;
|
|
}
|
|
String normalized = value.trim().toUpperCase(Locale.ROOT);
|
|
if (!normalized.matches("[A-Z][A-Z0-9_$#]{0,127}")) {
|
|
throw new AppException("Oracle identifier 형식이 올바르지 않습니다: " + value);
|
|
}
|
|
return normalized;
|
|
}
|
|
}
|