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 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>> vpdTargetsCache = new ConcurrentHashMap<>(); private final AtomicReference> formOptionsCache = new AtomicReference<>(); public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) { this.mapper = mapper; this.jdbcTemplate = jdbcTemplate; this.aiClient = aiClient; } public List findPolicies() { return mapper.findPolicies(); } public List findVpdTargets() { return findVpdTargets(null); } public List findVpdTargets(String owner) { String normalizedOwner = normalizeOptionalIdentifier(owner); String cacheKey = normalizedOwner == null ? "__MANAGED__" : normalizedOwner; CacheEntry> cached = vpdTargetsCache.get(cacheKey); if (cached != null && !cached.expired()) { return cached.value(); } List targets = List.copyOf(mapper.findVpdTargets(normalizedOwner)); vpdTargetsCache.put(cacheKey, new CacheEntry<>(targets, System.currentTimeMillis() + CATALOG_CACHE_MILLIS)); return targets; } public VpdPolicyFormOptions formOptions() { CacheEntry cached = formOptionsCache.get(); if (cached != null && !cached.expired()) { return cached.value(); } List 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 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 findPolicyDescriptionMap() { return descriptionMap(mapper.findPolicyDescriptions()); } public Map 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 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 descriptionMap(List notes) { Map 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 buildPolicyTemplateOptions( List functions, List existingTemplates ) { List 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 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 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 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; } }