Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -132,6 +132,33 @@ public class BackofficeSchemaService {
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
)
"""),
new TableDefinition("CB_MASKING_RULE", """
CREATE TABLE cb_masking_rule (
rule_id NUMBER PRIMARY KEY,
rule_code VARCHAR2(64) NOT NULL UNIQUE,
rule_name VARCHAR2(100) NOT NULL,
template_code VARCHAR2(30) NOT NULL,
description VARCHAR2(400),
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
)
"""),
new TableDefinition("CB_COLUMN_MASKING_RULE", """
CREATE TABLE cb_column_masking_rule (
column_id NUMBER PRIMARY KEY,
rule_id NUMBER NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""),
new TableDefinition("CB_USER_MASKING_RULE", """
CREATE TABLE cb_user_masking_rule (
user_id NUMBER NOT NULL,
column_id NUMBER NOT NULL,
decision VARCHAR2(10) NOT NULL CHECK (decision IN ('MASK','UNMASK')),
active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT cb_user_masking_rule_pk PRIMARY KEY (user_id, column_id)
)
"""),
new TableDefinition("CB_ORDS_PROBE_AUDIT", """
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
@@ -224,6 +251,25 @@ public class BackofficeSchemaService {
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP)
""";
private static final List<MaskingRuleSeed> DEFAULT_MASKING_RULES = List.of(
new MaskingRuleSeed("MASK_NULLIFY", "값 숨김 (NULL)", "NULLIFY",
"값을 NULL로 반환하는 기본 마스킹 방식"),
new MaskingRuleSeed("MASK_FULL", "전체 마스킹", "FULL",
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
);
private static final String MASKING_RULE_SEED_SQL = """
MERGE INTO cb_masking_rule dst
USING (SELECT ? rule_id, ? rule_code, ? rule_name, ? template_code, ? description FROM dual) src
ON (dst.rule_code = src.rule_code)
WHEN NOT MATCHED THEN INSERT (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
VALUES (src.rule_id, src.rule_code, src.rule_name, src.template_code, src.description, 'Y')
""";
private final JdbcTemplate jdbcTemplate;
private final BackofficeProperties properties;
@@ -342,6 +388,7 @@ public class BackofficeSchemaService {
}
runDml(results, "CB_PROTECTED_COLUMN", "DATA", PROTECTED_COLUMN_MIGRATION_SQL,
"민감 컬럼 legacy 값을 보강했습니다.", "UPDATED");
seedDefaultMaskingRules(results);
seedDefaultSettings(results);
return results;
}
@@ -412,6 +459,28 @@ public class BackofficeSchemaService {
}
}
private void seedDefaultMaskingRules(List<SchemaActionResult> results) {
long nextRuleId;
try {
Long currentMax = jdbcTemplate.queryForObject("SELECT NVL(MAX(rule_id), 0) FROM cb_masking_rule", Long.class);
nextRuleId = currentMax == null ? 1L : currentMax + 1L;
} catch (RuntimeException exception) {
results.add(new SchemaActionResult("CB_MASKING_RULE", "MASKING_RULE", "FAILED",
safeMessage(exception), MASKING_RULE_SEED_SQL));
return;
}
for (MaskingRuleSeed seed : DEFAULT_MASKING_RULES) {
try {
jdbcTemplate.update(MASKING_RULE_SEED_SQL, nextRuleId++, seed.code(), seed.name(), seed.templateCode(), seed.description());
results.add(new SchemaActionResult(seed.code(), "MASKING_RULE", "MERGED",
"기본 마스킹 규칙을 확인했습니다.", MASKING_RULE_SEED_SQL));
} catch (RuntimeException exception) {
results.add(new SchemaActionResult(seed.code(), "MASKING_RULE", "FAILED",
safeMessage(exception), MASKING_RULE_SEED_SQL));
}
}
}
private String currentUser() {
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
}
@@ -623,6 +692,7 @@ public class BackofficeSchemaService {
appendSql(builder, column.ddl());
}
appendSql(builder, PROTECTED_COLUMN_MIGRATION_SQL);
appendSql(builder, MASKING_RULE_SEED_SQL.replace("?", "'<MASKING_RULE_VALUE>'"));
appendSql(builder, SETTINGS_MERGE_SQL.replace("?", "'<BACKOFFICE_ORDS_BASE_URL>'"));
return builder.toString();
}
@@ -635,6 +705,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
@sql/adb/62_kb_aso_masking_backoffice_metadata.sql
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
@@ -644,8 +715,11 @@ public class BackofficeSchemaService {
-- 3. 대표 권한 부여 SQL
CONNECT %s/<password>@<tns_alias>
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT EXECUTE ON cb_agent_can_read_column TO cb_ords;
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
@sql/adb/64_kb_aso_masking_default_column_rules.sql
@sql/adb/63_kb_aso_masking_rule_runtime.sql
""".formatted(owner.toLowerCase());
}
@@ -693,4 +767,7 @@ public class BackofficeSchemaService {
private record ConstraintDefinition(String name, String table, String objectType) {
}
private record MaskingRuleSeed(String code, String name, String templateCode, String description) {
}
}

View File

@@ -1,37 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
/**
* Keeps the shared user/role/permission services independent from the DDS app.
* The normal VPD application has no synchronizer. The DDS application provides
* one and receives the change before the management request returns.
*/
@Service
public class DdsAuthorizationChangeNotifier {
private static final Logger log = LoggerFactory.getLogger(DdsAuthorizationChangeNotifier.class);
private final ObjectProvider<DdsAuthorizationSynchronizer> synchronizer;
public DdsAuthorizationChangeNotifier(ObjectProvider<DdsAuthorizationSynchronizer> synchronizer) {
this.synchronizer = synchronizer;
}
private DdsAuthorizationChangeNotifier() {
this.synchronizer = null;
}
public static DdsAuthorizationChangeNotifier noop() {
return new DdsAuthorizationChangeNotifier();
}
public void changed(String reason) {
if (synchronizer != null) {
log.info("DDS authorization change published: {}", reason);
synchronizer.ifAvailable(target -> target.synchronize(reason));
}
}
}

View File

@@ -1,7 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
/** Optional bridge implemented only by the dedicated DDS application. */
public interface DdsAuthorizationSynchronizer {
void synchronize(String reason);
}

View File

@@ -0,0 +1,41 @@
package com.cloudhandson.vpdbackoffice.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
/**
* Keeps user/role/permission services independent from optional external
* authorization runtimes. The normal VPD application can run without a
* synchronizer; if one exists, it receives the change before the management
* request returns.
*/
@Service
public class ExternalAuthorizationChangeNotifier {
private static final Logger log = LoggerFactory.getLogger(ExternalAuthorizationChangeNotifier.class);
private final ObjectProvider<ExternalAuthorizationSynchronizer> synchronizer;
public ExternalAuthorizationChangeNotifier(ObjectProvider<ExternalAuthorizationSynchronizer> synchronizer) {
this.synchronizer = synchronizer;
}
private ExternalAuthorizationChangeNotifier() {
this.synchronizer = null;
}
public static ExternalAuthorizationChangeNotifier noop() {
return new ExternalAuthorizationChangeNotifier();
}
public void changed(String reason) {
if (synchronizer != null) {
ExternalAuthorizationSynchronizer target = synchronizer.getIfAvailable();
if (target != null) {
log.info("External authorization change synchronized: {}", reason);
target.synchronize(reason);
}
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cloudhandson.vpdbackoffice.service;
/** Optional bridge implemented by an external authorization runtime. */
public interface ExternalAuthorizationSynchronizer {
void synchronize(String reason);
}

View File

@@ -16,21 +16,21 @@ public class GroupService {
private final GroupMapper groupMapper;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
@Autowired
public GroupService(
GroupMapper groupMapper,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
) {
this.groupMapper = groupMapper;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
this.authorizationChangeNotifier = authorizationChangeNotifier;
}
public GroupService(GroupMapper groupMapper, AuditService auditService) {
this(groupMapper, auditService, DdsAuthorizationChangeNotifier.noop());
this(groupMapper, auditService, ExternalAuthorizationChangeNotifier.noop());
}
public List<AppGroup> findAll() {
@@ -50,7 +50,7 @@ public class GroupService {
long groupId = groupMapper.nextGroupId();
groupMapper.insertGroup(groupId, command);
auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode()));
ddsAuthorizationChangeNotifier.changed("GROUP_CREATED");
authorizationChangeNotifier.changed("GROUP_CREATED");
}
@Transactional
@@ -69,7 +69,7 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",active=" + active));
ddsAuthorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED");
authorizationChangeNotifier.changed("GROUP_ACTIVE_CHANGED");
}
@Transactional
@@ -77,7 +77,7 @@ public class GroupService {
groupMapper.insertGroupUser(groupId, userId);
auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
ddsAuthorizationChangeNotifier.changed("GROUP_USER_ADDED");
authorizationChangeNotifier.changed("GROUP_USER_ADDED");
}
@Transactional
@@ -96,7 +96,7 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",userId=" + userId));
ddsAuthorizationChangeNotifier.changed("GROUP_USER_REMOVED");
authorizationChangeNotifier.changed("GROUP_USER_REMOVED");
}
@Transactional
@@ -104,7 +104,7 @@ public class GroupService {
groupMapper.insertGroupRole(groupId, roleId);
auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_ADDED");
authorizationChangeNotifier.changed("GROUP_ROLE_ADDED");
}
@Transactional
@@ -123,6 +123,6 @@ public class GroupService {
}
auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null,
"groupId=" + groupId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("GROUP_ROLE_REMOVED");
authorizationChangeNotifier.changed("GROUP_ROLE_REMOVED");
}
}

View File

@@ -0,0 +1,359 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Reconciles the backoffice masking metadata with the managed Oracle
* Data Redaction policies. Metadata is the source of truth: a table with no
* active linked columns has its managed policy disabled, rather than silently
* retaining redaction after its UI configuration was removed.
*/
@Service
public class MaskingPolicySynchronizer {
private static final String OWNER = "POC_2";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
private final JdbcTemplate jdbcTemplate;
private final MaskingRuleMapper mapper;
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
this.jdbcTemplate = jdbcTemplate;
this.mapper = mapper;
}
private static Map<String, String> managedPolicyMap() {
Map<String, String> policies = new LinkedHashMap<>();
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
return Collections.unmodifiableMap(policies);
}
public Set<String> managedObjectNames() {
return MANAGED_POLICIES.keySet();
}
public boolean isManagedObject(String objectName) {
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
}
static String managedPolicyName(String objectName) {
return MANAGED_POLICIES.get(objectName);
}
/**
* Applies the current active column-rule metadata to managed DBMS_REDACT policies.
*
* <p>The backoffice metadata is the source of truth. In particular, when an object has no
* active linked column rule, its policy is disabled. This avoids an old redaction policy
* continuing to mask data after an operator removed every rule from the UI.</p>
*/
public MaskingPolicySyncResult synchronize() {
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
if (OWNER.equalsIgnoreCase(rule.owner())
&& rule.ruleEnabled()
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
}
}
int disabledPolicies = 0;
int enabledPolicies = 0;
int addedColumns = 0;
int modifiedColumns = 0;
int droppedColumns = 0;
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
String objectName = policy.getKey();
String policyName = policy.getValue();
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
String enableStatus = policyEnableStatus(objectName, policyName);
if (desired.isEmpty()) {
if ("YES".equals(enableStatus)) {
disablePolicy(objectName, policyName);
disabledPolicies++;
}
continue;
}
boolean exists = enableStatus != null;
if (exists) {
if (!"YES".equals(enableStatus)) {
enablePolicy(objectName, policyName);
enabledPolicies++;
}
}
Set<String> desiredColumns = desired.stream()
.map(ColumnMaskingRule::columnName)
.map(this::requiredColumnName)
.collect(LinkedHashSet::new, Set::add, Set::addAll);
Set<String> actualColumns = exists
? new LinkedHashSet<>(redactionColumns(objectName))
: new LinkedHashSet<>();
for (String actualColumn : actualColumns) {
if (!desiredColumns.contains(actualColumn)) {
dropColumn(objectName, policyName, actualColumn);
droppedColumns++;
}
}
boolean firstColumn = !exists;
for (ColumnMaskingRule desiredColumn : desired) {
String columnName = requiredColumnName(desiredColumn.columnName());
if (firstColumn) {
addPolicy(objectName, policyName, columnName, desiredColumn.template());
firstColumn = false;
addedColumns++;
} else if (actualColumns.contains(columnName)) {
modifyColumn(objectName, policyName, columnName, desiredColumn.template());
modifiedColumns++;
} else {
addColumn(objectName, policyName, columnName, desiredColumn.template());
addedColumns++;
}
upsertColumnExpression(objectName, columnName, desiredColumn.columnId());
}
}
return new MaskingPolicySyncResult(
disabledPolicies, enabledPolicies, addedColumns, modifiedColumns, droppedColumns
);
}
private String policyEnableStatus(String objectName, String policyName) {
List<String> statuses = jdbcTemplate.queryForList("""
SELECT enable
FROM redaction_policies
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
""", String.class, OWNER, objectName, policyName);
return statuses.isEmpty() ? null : statuses.getFirst();
}
private List<String> redactionColumns(String objectName) {
return jdbcTemplate.queryForList("""
SELECT column_name
FROM redaction_columns
WHERE object_owner = ? AND object_name = ?
""", String.class, OWNER, objectName).stream()
.map(this::requiredColumnName)
.toList();
}
private void disablePolicy(String objectName, String policyName) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END;
""", OWNER, objectName, policyName);
}
private void enablePolicy(String objectName, String policyName) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
END;
""", OWNER, objectName, policyName);
}
private void dropColumn(String objectName, String policyName, String columnName) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
);
END;
""", OWNER, objectName, policyName, columnName);
}
private void addPolicy(
String objectName, String policyName, String columnName, MaskingTemplate template
) {
callTemplate("ADD_POLICY", objectName, policyName, columnName, template);
}
private void addColumn(
String objectName, String policyName, String columnName, MaskingTemplate template
) {
callTemplate("ADD_COLUMN", objectName, policyName, columnName, template);
}
private void modifyColumn(
String objectName, String policyName, String columnName, MaskingTemplate template
) {
callTemplate("MODIFY_COLUMN", objectName, policyName, columnName, template);
}
/**
* Template choice is an enum, so DBMS_REDACT constants are rendered only from trusted source
* code. They cannot be supplied from a request parameter or backoffice table value.
*/
private void callTemplate(
String operation, String objectName, String policyName, String columnName, MaskingTemplate template
) {
String functionConstant = switch (template) {
case NULLIFY -> "DBMS_REDACT.NULLIFY";
case FULL -> "DBMS_REDACT.FULL";
case TEXT_PARTIAL, RRN_PARTIAL -> "DBMS_REDACT.REGEXP";
};
String actionConstant = switch (operation) {
case "ADD_POLICY" -> null;
case "ADD_COLUMN" -> "DBMS_REDACT.ADD_COLUMN";
case "MODIFY_COLUMN" -> "DBMS_REDACT.MODIFY_COLUMN";
default -> throw new IllegalArgumentException("Unsupported redaction operation");
};
String regexPattern = switch (template) {
case TEXT_PARTIAL -> "(^.).*$";
case RRN_PARTIAL -> "(^[0-9]{6})-?[0-9]{7}$";
default -> null;
};
String regexReplacement = switch (template) {
case TEXT_PARTIAL -> "\\1***";
case RRN_PARTIAL -> "\\1-*******";
default -> null;
};
if ("ADD_POLICY".equals(operation)) {
String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL
? """
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
policy_description => 'Managed by VPD masking backoffice',
column_name => ?, function_type => %s, expression => '1=1',
regexp_pattern => ?, regexp_replace_string => ?, enable => TRUE
);
END;
""".formatted(functionConstant)
: """
BEGIN
DBMS_REDACT.ADD_POLICY(
object_schema => ?, object_name => ?, policy_name => ?,
policy_description => 'Managed by VPD masking backoffice',
column_name => ?, function_type => %s, expression => '1=1', enable => TRUE
);
END;
""".formatted(functionConstant);
if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
} else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
}
return;
}
String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL
? """
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => ?, object_name => ?, policy_name => ?, action => %s,
column_name => ?, function_type => %s, regexp_pattern => ?, regexp_replace_string => ?
);
END;
""".formatted(actionConstant, functionConstant)
: """
BEGIN
DBMS_REDACT.ALTER_POLICY(
object_schema => ?, object_name => ?, policy_name => ?, action => %s,
column_name => ?, function_type => %s
);
END;
""".formatted(actionConstant, functionConstant);
if (regexPattern == null) {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
} else {
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
}
}
private void upsertColumnExpression(String objectName, String columnName, long columnId) {
String expressionName = "CBMR_" + columnId;
String expression = maskingExpression(columnId);
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM redaction_expressions WHERE policy_expression_name = ?
""", Integer.class, expressionName);
if (count != null && count > 0) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.UPDATE_POLICY_EXPRESSION(
policy_expression_name => ?, expression => ?,
policy_expression_description => 'Mask unless trusted context allows original value'
);
END;
""", expressionName, expression);
} else {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.CREATE_POLICY_EXPRESSION(
policy_expression_name => ?, expression => ?,
policy_expression_description => 'Mask unless trusted context allows original value'
);
END;
""", expressionName, expression);
}
if (!expressionAppliedToColumn(expressionName, objectName, columnName)) {
jdbcTemplate.update("""
BEGIN
DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL(
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
);
END;
""", OWNER, objectName, columnName, expressionName);
}
}
static String maskingExpression(long columnId) {
return "SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId
+ "') IS NULL OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId + "') <> 'Y'";
}
private boolean expressionAppliedToColumn(String expressionName, String objectName, String columnName) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM redaction_expressions
WHERE policy_expression_name = ?
AND object_name = ?
AND column_name = ?
""", Integer.class, expressionName, objectName, columnName);
return count != null && count > 0;
}
private String requiredColumnName(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase();
if (!COLUMN_NAME.matcher(normalized).matches()) {
throw new AppException("동기화할 보호 컬럼명이 유효하지 않습니다.");
}
return normalized;
}
public record MaskingPolicySyncResult(
int disabledPolicies,
int enabledPolicies,
int addedColumns,
int modifiedColumns,
int droppedColumns
) {
public String summary() {
return "DB ASO 정책 동기화 완료: 비활성 " + disabledPolicies + "건, 활성 " + enabledPolicies
+ "건, 컬럼 추가 " + addedColumns + "건, 변경 " + modifiedColumns + "건, 해제 "
+ droppedColumns + "";
}
}
}

View File

@@ -0,0 +1,223 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MaskingRuleService {
private static final Pattern RULE_CODE = Pattern.compile("[A-Z][A-Z0-9_]{2,63}");
private static final Set<String> DECISIONS = Set.of("MASK", "UNMASK");
private final MaskingRuleMapper mapper;
private final UserMapper userMapper;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
public MaskingRuleService(
MaskingRuleMapper mapper,
UserMapper userMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService,
MaskingPolicySynchronizer maskingPolicySynchronizer
) {
this.mapper = mapper;
this.userMapper = userMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
}
public List<MaskingRule> findAllRules() {
return mapper.findAllRules();
}
public List<MaskingRule> findEnabledRules() {
return mapper.findEnabledRules();
}
public List<ColumnMaskingRule> findColumnRules() {
return mapper.findColumnRules();
}
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses();
}
public Set<String> managedObjectNames() {
return maskingPolicySynchronizer.managedObjectNames();
}
public List<UserMaskingRule> findUserRules() {
return mapper.findUserRules();
}
@Transactional
public void createRule(MaskingRuleCreateCommand command) {
String code = normalizeCode(command.ruleCode());
String name = normalizeRequired(command.ruleName(), 100, "규칙명");
String templateCode = normalizeTemplate(command.templateCode());
String description = normalizeOptional(command.description(), 400, "설명");
if (mapper.findRuleByCode(code) != null) {
throw new AppException("이미 등록된 컬럼 마스킹 규칙 코드입니다: " + code);
}
long ruleId = mapper.nextRuleId();
mapper.insertRule(ruleId, new MaskingRuleCreateCommand(code, name, templateCode, description));
auditService.record(new AuditEvent("MASKING_RULE_CREATED", null, null, "SUCCESS", null, null,
"ruleId=" + ruleId + ",code=" + code + ",template=" + templateCode));
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult setRuleActive(long ruleId, boolean active) {
if (mapper.updateRuleActive(ruleId, active ? "Y" : "N") == 0) {
throw new AppException("컬럼 마스킹 규칙을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("MASKING_RULE_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"ruleId=" + ruleId + ",active=" + active));
return synchronizeDatabasePolicies();
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult assignRuleToColumn(long columnId, long ruleId) {
var column = protectedObjectService.findColumn(columnId);
if (column == null) {
throw new AppException("보호 컬럼을 찾을 수 없습니다.");
}
if (!column.sensitive()) {
throw new AppException("민감 표시 보호 컬럼에만 컬럼 마스킹 규칙을 연결할 수 있습니다.");
}
MaskingRule rule = requireEnabledRule(ruleId);
mapper.upsertColumnRule(columnId, ruleId);
auditService.record(new AuditEvent("COLUMN_MASKING_RULE_ASSIGNED", null, column.objectId(), "SUCCESS", null, null,
"columnId=" + columnId + ",rule=" + rule.ruleCode()));
return synchronizeDatabasePolicies();
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult addTargetColumn(long objectId, String columnName) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
if (!maskingPolicySynchronizer.isManagedObject(object.objectName())) {
throw new AppException("ASO 마스킹 정책 관리 대상 객체가 아닙니다: " + object.objectName());
}
var column = protectedObjectService.addSensitiveColumnTarget(objectId, columnName);
auditService.record(new AuditEvent("MASKING_TARGET_COLUMN_REGISTERED", null, objectId, "SUCCESS", null, null,
object.objectName() + "." + column.columnName()));
return synchronizeDatabasePolicies();
}
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult removeRuleFromColumn(long columnId) {
mapper.deleteUserRulesForColumn(columnId);
if (mapper.deleteColumnRule(columnId) == 0) {
throw new AppException("해제할 컬럼 마스킹 규칙을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("COLUMN_MASKING_RULE_REMOVED", null, null, "SUCCESS", null, null,
"columnId=" + columnId));
return synchronizeDatabasePolicies();
}
/**
* Reconciles the current metadata with Oracle Data Redaction. This is exposed for the one-time
* repair of settings saved before automatic synchronization was introduced.
*/
@Transactional
public MaskingPolicySynchronizer.MaskingPolicySyncResult synchronizeDatabasePolicies() {
MaskingPolicySynchronizer.MaskingPolicySyncResult result = maskingPolicySynchronizer.synchronize();
auditService.record(new AuditEvent("MASKING_POLICY_SYNCHRONIZED", null, null, "SUCCESS", null, null,
"disabled=" + result.disabledPolicies() + ",enabled=" + result.enabledPolicies()
+ ",added=" + result.addedColumns() + ",modified=" + result.modifiedColumns()
+ ",dropped=" + result.droppedColumns()));
return result;
}
@Transactional
public void assignUserRule(long userId, long columnId, String decision) {
if (userMapper.findById(userId) == null) {
throw new AppException("사용자를 찾을 수 없습니다.");
}
ColumnMaskingRule columnRule = mapper.findColumnRule(columnId);
if (columnRule == null || !columnRule.ruleEnabled()) {
throw new AppException("먼저 활성 컬럼 마스킹 규칙을 민감 컬럼에 연결하세요.");
}
String normalizedDecision = normalizeDecision(decision);
mapper.upsertUserRule(userId, columnId, normalizedDecision);
auditService.record(new AuditEvent("USER_MASKING_RULE_ASSIGNED", userId, columnRule.objectId(), "SUCCESS", null, null,
"columnId=" + columnId + ",decision=" + normalizedDecision));
}
@Transactional
public void removeUserRule(long userId, long columnId) {
if (mapper.deleteUserRule(userId, columnId) == 0) {
throw new AppException("해제할 사용자별 컬럼 마스킹 규칙을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("USER_MASKING_RULE_REMOVED", userId, null, "SUCCESS", null, null,
"columnId=" + columnId));
}
private MaskingRule requireEnabledRule(long ruleId) {
MaskingRule rule = mapper.findRuleById(ruleId);
if (rule == null || !rule.enabled()) {
throw new AppException("활성 컬럼 마스킹 규칙을 선택하세요.");
}
return rule;
}
private String normalizeCode(String value) {
String normalized = normalizeRequired(value, 64, "규칙 코드").toUpperCase(Locale.ROOT);
if (!RULE_CODE.matcher(normalized).matches()) {
throw new AppException("규칙 코드는 영문 대문자·숫자·밑줄로 3~64자여야 합니다.");
}
return normalized;
}
private String normalizeTemplate(String value) {
try {
return MaskingTemplate.from(normalizeRequired(value, 30, "마스킹 템플릿")).code();
} catch (IllegalArgumentException exception) {
throw new AppException(exception.getMessage());
}
}
private String normalizeDecision(String value) {
String normalized = normalizeRequired(value, 10, "사용자별 적용 방식").toUpperCase(Locale.ROOT);
if (!DECISIONS.contains(normalized)) {
throw new AppException("사용자별 적용 방식은 MASK 또는 UNMASK만 가능합니다.");
}
return normalized;
}
private String normalizeRequired(String value, int maxLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
throw new AppException(label + "은(는) 필수입니다.");
}
if (normalized.length() > maxLength) {
throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다.");
}
return normalized;
}
private String normalizeOptional(String value, int maxLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() > maxLength) {
throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다.");
}
return normalized.isEmpty() ? null : normalized;
}
}

View File

@@ -130,7 +130,7 @@ public class McpChatbotService {
선택한 tool: %s
라우팅 근거: %s
Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 VPD/ORDS 결과까지 조회합니다.
Bearer Token이 없어 ORDS tools/call은 실행하지 않았습니다. 토큰을 입력하면 실제 ORDS 행 접근 결과까지 조회합니다.
""".formatted(tool.name(), routingReason);
}
@@ -141,13 +141,13 @@ public class McpChatbotService {
int rowCount = payload.path("rowCount").asInt(0);
JsonNode maskedColumns = payload.path("maskedColumns");
return """
질문을 MCP tool로 라우팅해 ORDS/VPD 결과를 조회했습니다.
질문을 MCP tool로 라우팅해 ORDS 행 접근 결과를 조회했습니다.
선택한 tool: %s
라우팅 근거: %s
ORDS 상태: %s
반환 행 수: %d
NULL 처리 컬럼: %s
ASO 마스킹 확인 컬럼: %s
질문: %s
""".formatted(tool.name(), routingReason, status, rowCount, maskedColumns.toString(), question);
@@ -155,7 +155,7 @@ public class McpChatbotService {
private String systemPrompt() {
return """
당신은 Oracle ORDS/VPD MCP 라우팅 결과를 설명하는 운영 보조자입니다.
당신은 Oracle ORDS 행 접근 MCP 라우팅 결과를 설명하는 운영 보조자입니다.
제공된 MCP tool 결과 JSON만 근거로 한국어로 간결하게 답변하세요.
Bearer Token 원문은 절대 출력하지 마세요.
""";
@@ -180,7 +180,7 @@ public class McpChatbotService {
답변 형식:
1. 한 문장 요약
2. 선택한 tool과 근거
3. 행 필터/컬럼 NULL 처리/오류 여부
3. 행 접근 필터(VPD)/ASO 컬럼 마스킹/오류 여부
4. 운영자가 다음에 확인할 것
""".formatted(question, tool.name(), tool.displayName(), tool.ordsPath(), routingReason, clientResult.toolsCallResponse());
}

View File

@@ -134,7 +134,7 @@ public class McpReasoningService {
private String buildPrompt(String question, McpToolView tool, String evidenceJson) {
String normalizedQuestion = question == null || question.isBlank()
? "요약부터 작성해줘. 이 ORDS/VPD 검증 결과에서 조회 행 수, 주요 식별자, NULL 처리 여부, 권한 범위, 다음 확인 조치를 정리해줘."
? "요약부터 작성해줘. 이 ORDS 행 접근 검증 결과에서 조회 행 수, 주요 식별자, ASO 마스킹 여부, 권한 범위, 다음 확인 조치를 정리해줘."
: question.trim();
return """
질문:
@@ -155,13 +155,13 @@ public class McpReasoningService {
- 그 다음 "## 판단 근거" 섹션에 표를 사용해 rowCount, maskedColumns, status, errorCode를 정리한다.
- 그 다음 "## 상세" 섹션에서 반환 행과 권한 범위를 설명한다.
- 마지막 "## 다음 조치" 섹션은 운영자가 확인할 항목만 짧게 쓴다.
- VPD 행 필터, 컬럼 NULL 처리, ORDS 오류 여부를 구분한다.
- 행 접근 필터(VPD), ASO 컬럼 마스킹, ORDS 오류 여부를 구분한다.
""".formatted(normalizedQuestion, tool.name(), tool.displayName(), tool.ordsPath(), evidenceJson);
}
private String systemPrompt() {
return """
당신은 Oracle ADB VPD/Redaction/ORDS 권한 검증 보조자입니다.
당신은 Oracle ADB 행 접근(VPD)/Redaction/ORDS 권한 검증 보조자입니다.
백오피스가 제공한 도구 실행 증거만 근거로 판단하고, 토큰 원문이나 비밀 값을 재출력하지 마세요.
""";
}

View File

@@ -1,8 +1,6 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -10,30 +8,41 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
@Service
public class McpSseService {
private static final String SELECT_AI_ROUTER_TOOL = "ords.agent.kb_select_ai_router";
private static final String SELECT_AI_ROUTER_PATH = "cb-ords/kb-select-ai-agent/run";
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
private static final String SELECT_AI_VPD_QUERY_PROFILE =
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
-1L,
"KB Select AI 행 접근 자연어 조회",
SELECT_AI_VPD_QUERY_PATH
);
private final McpToolRegistry toolRegistry;
private final OrdsProbeService ordsProbeService;
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
private final ObjectMapper objectMapper;
public McpSseService(
McpToolRegistry toolRegistry,
OrdsProbeService ordsProbeService,
SelectAiAgentOrdsService selectAiAgentOrdsService,
ObjectMapper objectMapper
) {
this.toolRegistry = toolRegistry;
this.ordsProbeService = ordsProbeService;
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
this.objectMapper = objectMapper;
}
public ObjectNode handle(String contextPath, JsonNode request) {
return handle(contextPath, request, "");
}
/**
* The HTTP bearer token is the business-user subject token; no separate MCP token is used.
*/
public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) {
ObjectNode response = objectMapper.createObjectNode();
response.put("jsonrpc", "2.0");
if (request != null && request.has("id")) {
@@ -41,12 +50,13 @@ public class McpSseService {
}
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
try {
response.set("result", switch (method) {
case "initialize" -> initializeResult(contextPath);
case "notifications/initialized" -> objectMapper.createObjectNode();
case "tools/list" -> toolsListResult();
case "tools/call" -> toolsCallResult(request.path("params"));
case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
});
} catch (Exception e) {
@@ -59,6 +69,11 @@ public class McpSseService {
return response;
}
/** The only tool registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(SELECT_AI_VPD_QUERY_VIEW);
}
private ObjectNode initializeResult(String contextPath) {
ObjectNode result = objectMapper.createObjectNode();
result.put("protocolVersion", "2024-11-05");
@@ -75,83 +90,35 @@ public class McpSseService {
private ObjectNode toolsListResult() {
ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = objectMapper.createArrayNode();
for (McpToolView tool : toolRegistry.listTools()) {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", tool.name());
item.put("description", tool.description());
item.set("inputSchema", inputSchema(tool));
tools.add(item);
}
tools.add(selectAiRouterTool());
tools.add(selectAiVpdQueryTool());
result.set("tools", tools);
return result;
}
private ObjectNode inputSchema(McpToolView tool) {
private ObjectNode selectAiVpdQueryTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode bearerToken = objectMapper.createObjectNode();
bearerToken.put("type", "string");
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
properties.set("bearerToken", bearerToken);
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
ObjectNode limit = objectMapper.createObjectNode();
limit.put("type", "integer");
limit.put("description", "조회 row 제한. 1부터 500까지 허용");
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
limit.put("minimum", 1);
limit.put("maximum", 500);
limit.put("maximum", 100);
properties.set("limit", limit);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
if (isVectorTool(tool)) {
ObjectNode embedding = objectMapper.createObjectNode();
embedding.put("type", "array");
embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 개발 환경에서는 4차원 벡터를 사용합니다.");
ObjectNode items = objectMapper.createObjectNode();
items.put("type", "number");
embedding.set("items", items);
embedding.put("minItems", 1);
properties.set("embedding", embedding);
required.add("embedding");
}
schema.set("required", required);
schema.put("additionalProperties", false);
return schema;
}
private ObjectNode selectAiRouterTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_ROUTER_TOOL);
item.put("description", "Bearer Token으로 ORDS Select AI Team을 호출해 KB 원장 질의용 SQL을 생성합니다. Team의 VPD 컨텍스트가 적용됩니다.");
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode bearerToken = objectMapper.createObjectNode();
bearerToken.put("type", "string");
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
properties.set("bearerToken", bearerToken);
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 원장에 대해 생성할 SQL을 자연어로 요청합니다. 이 Team은 읽기 전용 SHOWSQL 생성만 허용합니다.");
prompt.put("maxLength", 8000);
properties.set("prompt", prompt);
ObjectNode conversationId = objectMapper.createObjectNode();
conversationId.put("type", "string");
conversationId.put("description", "선택값. 동일 대화 흐름을 이어갈 때 사용하는 안전한 식별자");
conversationId.put("pattern", "^[A-Za-z0-9._:-]{1,128}$");
properties.set("conversationId", conversationId);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
required.add("prompt");
schema.set("required", required);
schema.put("additionalProperties", false);
@@ -159,67 +126,32 @@ public class McpSseService {
return item;
}
private ObjectNode toolsCallResult(JsonNode params) {
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String toolName = params.path("name").asText("");
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
}
JsonNode arguments = params.path("arguments");
if (SELECT_AI_ROUTER_TOOL.equals(toolName)) {
return selectAiRouterCallResult(arguments);
String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
if (token.isBlank()) {
return tokenAccessDeniedResult();
}
McpToolView tool = findTool(toolName);
String bearerToken = arguments.path("bearerToken").asText("");
int limit = normalizeLimit(arguments.path("limit").asInt(50));
String requestBody = null;
if (isVectorTool(tool)) {
JsonNode embedding = arguments.get("embedding");
if (embedding == null || !embedding.isArray() || embedding.isEmpty()) {
throw new AppException("벡터 검색 tool에는 embedding 배열이 필요합니다.");
}
ObjectNode body = objectMapper.createObjectNode();
body.set("embedding", embedding);
requestBody = body.toString();
JsonNode response;
try {
response = selectAiAgentOrdsService.run(
token,
arguments.path("prompt").asText(""),
normalizeLimit(arguments.path("limit").asInt(50))
);
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
ProbeResult probeResult = ordsProbeService.runProbe(
new ProbeCommand(null, tool.objectId(), bearerToken, limit, requestBody));
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", tool.name());
payload.put("objectId", tool.objectId());
payload.put("object", tool.displayName());
payload.put("ordsPath", tool.ordsPath());
payload.put("status", probeResult.status().name());
payload.put("rowCount", probeResult.rowCount());
payload.set("columns", objectMapper.valueToTree(probeResult.columns()));
payload.set("maskedColumns", objectMapper.valueToTree(probeResult.maskedColumns()));
payload.set("rows", objectMapper.valueToTree(probeResult.rows()));
payload.put("errorCode", probeResult.errorCode());
payload.put("errorMessage", probeResult.errorMessage());
payload.put("requestHeaders", probeResult.requestHeaders());
payload.put("requestPayload", probeResult.requestPayload());
payload.put("responseHeaders", probeResult.responseHeaders());
payload.put("responseBody", probeResult.responseBody());
ObjectNode result = objectMapper.createObjectNode();
ArrayNode content = objectMapper.createArrayNode();
ObjectNode text = objectMapper.createObjectNode();
text.put("type", "text");
text.put("text", pretty(payload));
content.add(text);
result.set("content", content);
result.put("isError", probeResult.errorCode() != null);
return result;
}
private ObjectNode selectAiRouterCallResult(JsonNode arguments) {
JsonNode response = selectAiAgentOrdsService.run(
arguments.path("bearerToken").asText(""),
arguments.path("prompt").asText(""),
arguments.path("conversationId").asText("")
);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", SELECT_AI_ROUTER_TOOL);
payload.put("team", "KB_SELECT_AI_ROUTER_TEAM");
payload.put("ordsPath", SELECT_AI_ROUTER_PATH);
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
payload.set("response", response);
ObjectNode result = objectMapper.createObjectNode();
@@ -233,23 +165,27 @@ public class McpSseService {
return result;
}
private McpToolView findTool(String toolName) {
List<McpToolView> tools = toolRegistry.listTools();
return tools.stream()
.filter(tool -> tool.name().equals(toolName))
.findFirst()
.orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName));
}
private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED");
payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
private boolean isVectorTool(McpToolView tool) {
return tool != null && tool.displayName().toUpperCase().endsWith("CB_VECTOR_SEARCH_DOCUMENTS");
ObjectNode result = objectMapper.createObjectNode();
ArrayNode content = objectMapper.createArrayNode();
ObjectNode text = objectMapper.createObjectNode();
text.put("type", "text");
text.put("text", pretty(payload));
content.add(text);
result.set("content", content);
result.put("isError", true);
return result;
}
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;
}
return Math.min(limit, 500);
return Math.min(limit, 100);
}
private String pretty(Object value) {

View File

@@ -33,7 +33,7 @@ public class McpToolRegistry {
return new McpToolView(
name,
object.descriptionOrDefault() + " · "
+ object.displayName() + "을 Bearer Token으로 ORDS 호출해 VPD/표시 보호 결과를 조회합니다." + vectorHint,
+ object.displayName() + "을 Bearer Token으로 ORDS 호출해 행 접근/컬럼 표시 결과를 조회합니다." + vectorHint,
object.objectId(),
object.displayName(),
object.ordsPath()

View File

@@ -0,0 +1,231 @@
package com.cloudhandson.vpdbackoffice.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
import com.oracle.bmc.model.BmcException;
import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient;
import com.oracle.bmc.generativeaiinference.model.ChatDetails;
import com.oracle.bmc.generativeaiinference.model.ChatChoice;
import com.oracle.bmc.generativeaiinference.model.BaseChatResponse;
import com.oracle.bmc.generativeaiinference.model.GenericChatRequest;
import com.oracle.bmc.generativeaiinference.model.GenericChatResponse;
import com.oracle.bmc.generativeaiinference.model.JsonSchemaResponseFormat;
import com.oracle.bmc.generativeaiinference.model.OnDemandServingMode;
import com.oracle.bmc.generativeaiinference.model.ResponseJsonSchema;
import com.oracle.bmc.generativeaiinference.model.SystemMessage;
import com.oracle.bmc.generativeaiinference.model.TextContent;
import com.oracle.bmc.generativeaiinference.model.UserMessage;
import com.oracle.bmc.generativeaiinference.requests.ChatRequest;
import com.oracle.bmc.generativeaiinference.responses.ChatResponse;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.core.env.Environment;
/**
* Calls OCI Generative AI through the operator-managed OCI configuration profile.
*
* <p>The application neither reads nor stores a private key itself. OCI's SDK
* reads the path/profile supplied through the service environment and signs the
* request. This client is intentionally limited to non-streaming text chat.
* It does not execute SQL or hand the model a database connection.</p>
*/
@Service
public class OciGenerativeAiChatClient {
private final Environment environment;
private final ObjectMapper objectMapper;
public OciGenerativeAiChatClient(Environment environment, ObjectMapper objectMapper) {
this.environment = environment;
this.objectMapper = objectMapper;
}
public boolean configured() {
return missingConfigurationNames().isEmpty();
}
/** Returns setting names only; no configured value or credential is ever exposed. */
public String configurationHint() {
List<String> missing = missingConfigurationNames();
return missing.isEmpty() ? "" : String.join(", ", missing);
}
private List<String> missingConfigurationNames() {
OciSettings settings = settings();
return Stream.of(
setting(settings.enabled(), "BACKOFFICE_AI_ENABLED=true"),
setting("oci".equalsIgnoreCase(settings.provider()), "BACKOFFICE_AI_PROVIDER=oci"),
setting(hasText(settings.ociConfigFile()), "BACKOFFICE_AI_OCI_CONFIG_FILE"),
setting(hasText(settings.ociProfile()), "BACKOFFICE_AI_OCI_PROFILE"),
setting(hasText(settings.ociRegion()), "BACKOFFICE_AI_OCI_REGION"),
setting(hasText(settings.ociCompartmentId()), "BACKOFFICE_AI_OCI_COMPARTMENT_ID"),
setting(hasText(settings.baseUrl()), "BACKOFFICE_AI_BASE_URL"),
setting(hasText(settings.model()), "BACKOFFICE_AI_MODEL"))
.filter(value -> value != null)
.toList();
}
public String modelName() {
return settings().model();
}
/** Sends a bounded, source-only request and returns the first textual choice. */
public String chat(String systemPrompt, String userPrompt) {
if (!configured()) {
throw new AppException("OCI AI 호출 설정이 없습니다.");
}
OciSettings ai = settings();
try (GenerativeAiInferenceClient client = GenerativeAiInferenceClient.builder()
.region(ai.ociRegion())
.build(new ConfigFileAuthenticationDetailsProvider(ai.ociConfigFile(), ai.ociProfile()))) {
client.setEndpoint(ai.baseUrl());
ChatResponse response = client.chat(ChatRequest.builder()
.chatDetails(ChatDetails.builder()
.compartmentId(ai.ociCompartmentId())
.servingMode(OnDemandServingMode.builder().modelId(ai.model()).build())
.chatRequest(GenericChatRequest.builder()
.messages(List.of(
SystemMessage.builder().content(List.of(text(systemPrompt))).build(),
UserMessage.builder().content(List.of(text(userPrompt))).build()))
// SQL source plus block-level commentary can exceed 1,200
// completion tokens. GPT-5.5 can consume reasoning tokens
// before producing text, so leave a bounded 4K response budget.
.maxCompletionTokens(4_096)
// GPT-5.5 rejects temperature. The verified PoC route sends
// no temperature and explicitly requests one non-streaming response.
.isStream(false)
// Mirror the working PoC route: force a named JSON field so
// GPT-5.5 returns final text rather than only reasoning output.
.responseFormat(explanationResponseFormat())
.build())
.build())
.build());
return extractText(response);
} catch (AppException exception) {
throw exception;
} catch (BmcException exception) {
// Keep the operational hint useful without returning OCI's raw body,
// request IDs, request content, or any authentication detail to a browser.
throw new AppException("OCI Generative AI 설명 호출에 실패했습니다 (HTTP "
+ exception.getStatusCode() + ").");
} catch (Exception exception) {
throw new AppException("OCI Generative AI 설명 호출에 실패했습니다 ("
+ exception.getClass().getSimpleName() + ").");
}
}
private TextContent text(String value) {
return TextContent.builder().text(value).build();
}
private String extractText(ChatResponse response) {
if (response == null || response.getChatResult() == null) {
throw new AppException("OCI Generative AI 응답 본문이 없습니다.");
}
BaseChatResponse baseResponse = response.getChatResult().getChatResponse();
if (!(baseResponse instanceof GenericChatResponse generic)) {
throw new AppException("OCI Generative AI 응답 형식이 예상과 다릅니다 ("
+ safeType(baseResponse) + ").");
}
if (generic.getChoices() == null || generic.getChoices().isEmpty()) {
throw new AppException("OCI Generative AI 응답에 선택 결과가 없습니다.");
}
ChatChoice choice = generic.getChoices().getFirst();
if (choice.getMessage() == null || choice.getMessage().getContent() == null) {
throw new AppException("OCI Generative AI 응답에 메시지 콘텐츠가 없습니다.");
}
List<?> contents = choice.getMessage().getContent();
String result = contents.stream()
.filter(TextContent.class::isInstance)
.map(TextContent.class::cast)
.map(TextContent::getText)
.filter(this::hasText)
.reduce("", String::concat);
if (result.isBlank()) {
throw new AppException("OCI Generative AI 응답에 텍스트가 없습니다 (콘텐츠: "
+ contents.stream().map(this::safeType).distinct().reduce((left, right) -> left + ", " + right)
.orElse("없음") + ", 종료: " + safeFinishReason(choice.getFinishReason()) + ").");
}
return explanationFromJson(result);
}
private JsonSchemaResponseFormat explanationResponseFormat() {
Map<String, Object> schema = Map.of(
"type", "object",
"properties", Map.of("explanation", Map.of("type", "string")),
"required", List.of("explanation"),
"additionalProperties", false
);
return JsonSchemaResponseFormat.builder()
.jsonSchema(ResponseJsonSchema.builder()
.name("security_sql_explanation")
.description("Markdown explanation for an approved read-only security SQL script")
.schema(schema)
.isStrict(true)
.build())
.build();
}
private String explanationFromJson(String json) {
try {
JsonNode explanation = objectMapper.readTree(json).path("explanation");
if (explanation.isTextual() && !explanation.asText().isBlank()) {
return explanation.asText();
}
} catch (Exception ignored) {
// Fall through to the safe, actionable message below. The model output
// is untrusted and is never echoed into an exception message.
}
throw new AppException("OCI Generative AI 응답에 explanation JSON 필드가 없습니다.");
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private String setting(boolean configured, String name) {
return configured ? null : name;
}
private String safeType(Object value) {
return value == null ? "없음" : value.getClass().getSimpleName();
}
private String safeFinishReason(String value) {
return value == null || value.isBlank() ? "없음" : value.replaceAll("[^A-Za-z0-9_-]", "");
}
/**
* Reads the service environment directly. This is deliberate: OCI SDK API-key
* configuration is supplied by the operator's .env/EnvironmentFile, not by
* database data or a browser request. Values are never rendered or logged.
*/
private OciSettings settings() {
return new OciSettings(
Boolean.parseBoolean(environment.getProperty("BACKOFFICE_AI_ENABLED", "false")),
environment.getProperty("BACKOFFICE_AI_PROVIDER", ""),
environment.getProperty("BACKOFFICE_AI_BASE_URL", ""),
environment.getProperty("BACKOFFICE_AI_MODEL", ""),
environment.getProperty("BACKOFFICE_AI_OCI_CONFIG_FILE", ""),
environment.getProperty("BACKOFFICE_AI_OCI_PROFILE", ""),
environment.getProperty("BACKOFFICE_AI_OCI_REGION", ""),
environment.getProperty("BACKOFFICE_AI_OCI_COMPARTMENT_ID", "")
);
}
private record OciSettings(
boolean enabled,
String provider,
String baseUrl,
String model,
String ociConfigFile,
String ociProfile,
String ociRegion,
String ociCompartmentId
) {
}
}

View File

@@ -46,19 +46,19 @@ public class PermissionService {
private final PermissionMapper permissionMapper;
private final ProtectedObjectService protectedObjectService;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
@Autowired
public PermissionService(
PermissionMapper permissionMapper,
ProtectedObjectService protectedObjectService,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
) {
this.permissionMapper = permissionMapper;
this.protectedObjectService = protectedObjectService;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
this.authorizationChangeNotifier = authorizationChangeNotifier;
}
public PermissionService(
@@ -66,7 +66,7 @@ public class PermissionService {
ProtectedObjectService protectedObjectService,
AuditService auditService
) {
this(permissionMapper, protectedObjectService, auditService, DdsAuthorizationChangeNotifier.noop());
this(permissionMapper, protectedObjectService, auditService, ExternalAuthorizationChangeNotifier.noop());
}
public List<AppRole> findRoles() {
@@ -90,7 +90,7 @@ public class PermissionService {
long roleId = permissionMapper.nextRoleId();
permissionMapper.insertRole(roleId, roleName.trim(), description, normalizeSensitivityLevel(maxSensitivityLevel));
auditService.record(new AuditEvent("ROLE_CREATED", null, null, "SUCCESS", null, null, roleName));
ddsAuthorizationChangeNotifier.changed("ROLE_CREATED");
authorizationChangeNotifier.changed("ROLE_CREATED");
}
@Transactional
@@ -102,7 +102,7 @@ public class PermissionService {
}
auditService.record(new AuditEvent("ROLE_MAX_SENSITIVITY_UPDATED", null, null, "SUCCESS", null, null,
"roleId=" + roleId + ", max=" + normalized));
ddsAuthorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
authorizationChangeNotifier.changed("ROLE_MAX_SENSITIVITY_UPDATED");
}
@Transactional
@@ -128,7 +128,7 @@ public class PermissionService {
throw new AppException("삭제할 역할을 찾을 수 없습니다.");
}
auditService.record(new AuditEvent("ROLE_DELETED", null, null, "SUCCESS", null, null, "roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("ROLE_DELETED");
authorizationChangeNotifier.changed("ROLE_DELETED");
}
@Transactional
@@ -143,7 +143,6 @@ public class PermissionService {
}
protectedObjectService.assertEnabled(command.objectId());
validateRules(command.objectId(), command.rules());
validateVisibleColumns(command.objectId(), command.visibleColumns());
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
@@ -167,17 +166,12 @@ public class PermissionService {
}
permissionMapper.deleteVisibleColumns(permissionId);
if (command.visibleColumns() != null) {
for (String columnName : command.visibleColumns()) {
permissionMapper.insertVisibleColumn(permissionId, columnName.trim().toUpperCase(Locale.ROOT));
}
}
auditService.record(new AuditEvent(
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
"roleId=" + command.roleId()
));
ddsAuthorizationChangeNotifier.changed("PERMISSION_SAVED");
authorizationChangeNotifier.changed("PERMISSION_SAVED");
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", permissionEffect, List.of(), List.of());
}
@@ -203,7 +197,7 @@ public class PermissionService {
}
auditService.record(new AuditEvent("PERMISSION_DELETED", null, null, "SUCCESS", null, null,
"permissionId=" + permissionId));
ddsAuthorizationChangeNotifier.changed("PERMISSION_DELETED");
authorizationChangeNotifier.changed("PERMISSION_DELETED");
}
public int countPermissionsByObjectId(long objectId) {
@@ -305,21 +299,6 @@ public class PermissionService {
}
}
private void validateVisibleColumns(long objectId, List<String> visibleColumns) {
if (visibleColumns == null || visibleColumns.isEmpty()) {
return;
}
Set<String> allowed = new HashSet<>();
for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) {
allowed.add(column.columnName().toUpperCase(Locale.ROOT));
}
for (String columnName : visibleColumns) {
if (!allowed.contains(columnName.trim().toUpperCase(Locale.ROOT))) {
throw new AppException("등록되지 않은 컬럼입니다: " + columnName);
}
}
}
private String normalize(String value) {
return clean(value).toUpperCase(Locale.ROOT);
}

View File

@@ -7,12 +7,17 @@ import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCreateCommand;
import com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
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.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,13 +26,17 @@ public class ProtectedObjectService {
private final ProtectedObjectMapper mapper;
private final AuditService auditService;
private volatile CacheEntry<List<DatabaseObjectOption>> databaseObjectsCache;
private final AtomicReference<CacheEntry<List<DatabaseObjectOption>>> databaseObjectsCache =
new AtomicReference<>();
private final Map<String, CacheEntry<List<String>>> databaseColumnsCache = new ConcurrentHashMap<>();
private final Map<Long, CacheEntry<List<ProtectedColumn>>> protectedColumnsCache = new ConcurrentHashMap<>();
private static final long CATALOG_CACHE_MILLIS = 60_000L;
// Object and column metadata changes only through this service, which clears
// the affected cache entries. Keep dictionary metadata warm between screens.
private static final long CATALOG_CACHE_MILLIS = 15 * 60_000L;
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
private static final Set<String> REDACTION_METHODS = Set.of("NONE", "NULLIFY", "PARTIAL", "FULL");
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
public ProtectedObjectService(ProtectedObjectMapper mapper, AuditService auditService) {
this.mapper = mapper;
@@ -43,12 +52,12 @@ public class ProtectedObjectService {
}
public List<DatabaseObjectOption> findDatabaseObjects() {
CacheEntry<List<DatabaseObjectOption>> cached = databaseObjectsCache;
CacheEntry<List<DatabaseObjectOption>> cached = databaseObjectsCache.get();
if (cached != null && !cached.expired()) {
return cached.value();
}
List<DatabaseObjectOption> objects = List.copyOf(mapper.findDatabaseObjects());
databaseObjectsCache = new CacheEntry<>(objects, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
databaseObjectsCache.set(new CacheEntry<>(objects, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
return objects;
}
@@ -70,7 +79,7 @@ public class ProtectedObjectService {
}
if (isLegacyAutoPath(object.ordsPath(), object.owner(), object.objectName())) {
mapper.updateOrdsPath(object.objectId(), defaultOrdsPath(object.owner(), object.objectName()));
databaseObjectsCache = null;
databaseObjectsCache.set(null);
return mapper.findById(object.objectId());
}
return object;
@@ -86,6 +95,83 @@ public class ProtectedObjectService {
return columns;
}
/**
* Loads protected-object columns in one round trip. This is used by the
* permissions page, where requesting each object's columns independently
* turns a single page render into an ADB N+1 query pattern.
*/
public Map<Long, List<ProtectedColumn>> findColumnsByObjectIds(Collection<Long> objectIds) {
List<Long> requestedIds = objectIds.stream()
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
if (requestedIds.isEmpty()) {
return Map.of();
}
List<Long> missingIds = new ArrayList<>();
Map<Long, List<ProtectedColumn>> result = new LinkedHashMap<>();
for (Long objectId : requestedIds) {
CacheEntry<List<ProtectedColumn>> cached = protectedColumnsCache.get(objectId);
if (cached != null && !cached.expired()) {
result.put(objectId, cached.value());
} else {
missingIds.add(objectId);
}
}
if (!missingIds.isEmpty()) {
Map<Long, List<ProtectedColumn>> loaded = new LinkedHashMap<>();
for (ProtectedColumn column : mapper.findColumnsByObjectIds(missingIds)) {
loaded.computeIfAbsent(column.objectId(), ignored -> new ArrayList<>()).add(column);
}
long expiresAt = System.currentTimeMillis() + CATALOG_CACHE_MILLIS;
for (Long objectId : missingIds) {
List<ProtectedColumn> columns = List.copyOf(loaded.getOrDefault(objectId, List.of()));
protectedColumnsCache.put(objectId, new CacheEntry<>(columns, expiresAt));
result.put(objectId, columns);
}
}
return Map.copyOf(result);
}
public ProtectedColumn findColumn(long columnId) {
return mapper.findColumnById(columnId);
}
@Transactional
public ProtectedColumn addSensitiveColumnTarget(long objectId, String columnName) {
ProtectedObject object = assertEnabled(objectId);
String normalizedColumnName = normalizeColumnName(columnName);
Set<String> databaseColumns = new HashSet<>();
for (String databaseColumn : findDatabaseColumns(object.owner(), object.objectName())) {
databaseColumns.add(databaseColumn.toUpperCase(Locale.ROOT));
}
if (!databaseColumns.contains(normalizedColumnName)) {
throw new AppException("DB 객체에 존재하지 않는 컬럼입니다: "
+ object.owner() + "." + object.objectName() + "." + normalizedColumnName);
}
ProtectedColumn existing = mapper.findColumnByObjectAndName(objectId, normalizedColumnName);
if (existing != null) {
if (existing.sensitive()) {
return existing;
}
mapper.updateColumnPolicy(existing.columnId(), "CONFIDENTIAL", "FULL");
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_COLUMN_MASKING_TARGET_ENABLED", null, objectId, "SUCCESS", null,
null, normalizedColumnName));
return mapper.findColumnById(existing.columnId());
}
long columnId = mapper.nextColumnId();
mapper.insertColumn(columnId, objectId, normalizedColumnName, "Y", "CONFIDENTIAL", "FULL");
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_COLUMN_MASKING_TARGET_ADDED", null, objectId, "SUCCESS", null, null,
normalizedColumnName));
return mapper.findColumnById(columnId);
}
@Transactional
public void createObject(ProtectedObjectCreateCommand command) {
ProtectedObjectCreateCommand normalized = normalizeCreateCommand(command);
@@ -97,7 +183,7 @@ public class ProtectedObjectService {
mapper.insertColumn(mapper.nextColumnId(), objectId, column, sensitiveYn,
defaultSensitivityLevel(sensitiveYn), defaultRedactionMethod(sensitiveYn));
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_OBJECT_CREATED", null, objectId, "SUCCESS", null, null,
normalized.objectName()));
@@ -114,7 +200,7 @@ public class ProtectedObjectService {
if (isLegacyAutoPath(existing.ordsPath(), normalizedOwner, normalizedObjectName)) {
mapper.updateOrdsPath(existing.objectId(), defaultOrdsPath(normalizedOwner, normalizedObjectName));
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(existing.objectId());
auditService.record(new AuditEvent("PROTECTED_OBJECT_RE_ENABLED", null, existing.objectId(), "SUCCESS", null,
null, existing.displayName()));
@@ -122,7 +208,7 @@ public class ProtectedObjectService {
}
if (isLegacyAutoPath(existing.ordsPath(), normalizedOwner, normalizedObjectName)) {
mapper.updateOrdsPath(existing.objectId(), defaultOrdsPath(normalizedOwner, normalizedObjectName));
databaseObjectsCache = null;
databaseObjectsCache.set(null);
return mapper.findById(existing.objectId());
}
return existing;
@@ -145,7 +231,7 @@ public class ProtectedObjectService {
for (String column : columns) {
mapper.insertColumn(mapper.nextColumnId(), objectId, column, "N", "PUBLIC", "NONE");
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_OBJECT_AUTO_CREATED", null, objectId, "SUCCESS", null, null,
command.objectName()));
@@ -210,7 +296,7 @@ public class ProtectedObjectService {
if (updated == 0) {
throw new AppException("설명을 수정할 조회 대상을 찾을 수 없습니다.");
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
auditService.record(new AuditEvent("PROTECTED_OBJECT_DESCRIPTION_UPDATED", null, objectId, "SUCCESS", null, null,
normalized));
}
@@ -237,7 +323,7 @@ public class ProtectedObjectService {
if (updated == 0) {
throw new AppException("보호 객체를 찾을 수 없습니다.");
}
databaseObjectsCache = null;
databaseObjectsCache.set(null);
protectedColumnsCache.remove(objectId);
auditService.record(new AuditEvent("PROTECTED_OBJECT_DISABLED", null, objectId, "SUCCESS", null, null, null));
}
@@ -273,6 +359,14 @@ public class ProtectedObjectService {
return normalized;
}
private String normalizeColumnName(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!COLUMN_NAME.matcher(normalized).matches()) {
throw new AppException("컬럼명은 영문 대문자·숫자·밑줄로 된 DB 컬럼명이어야 합니다.");
}
return normalized;
}
private record CacheEntry<T>(T value, long expiresAt) {
boolean expired() {

View File

@@ -0,0 +1,268 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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 SchemaMetadataService {
private static final String OWNER = "POC_2";
private static final int MAX_COMMENT_LENGTH = 4000;
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final JdbcTemplate jdbcTemplate;
private final StructuredDataService structuredDataService;
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
this.jdbcTemplate = jdbcTemplate;
this.structuredDataService = structuredDataService;
}
public List<StructuredDataTable> tables() {
return structuredDataService.tables();
}
public String defaultKey() {
return structuredDataService.defaultKey();
}
public SchemaMetadataView find(String tableKey) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = table.tableName();
String tableComment = tableComment(tableName);
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView(
table,
nullToEmpty(tableComment),
annotations.getOrDefault(tableTargetKey(), List.of()),
columns
);
}
@Transactional
public void updateTableComment(String tableKey, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
+ " IS " + quoteLiteral(normalizedComment));
}
@Transactional
public void updateColumnComment(String tableKey, String columnName, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
}
@Transactional
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
}
@Transactional
public void updateColumnAnnotation(
String tableKey,
String columnName,
String annotationName,
String annotationValue
) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName);
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
}
private void updateAnnotation(
String tableName,
String columnName,
String annotationName,
String annotationValue
) {
String key = requireSimpleName(annotationName, "annotation name");
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
if (annotationExists(tableName, columnName, key)) {
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
}
if (!value.isBlank()) {
jdbcTemplate.execute(annotationSql(tableName, columnName,
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
}
}
private String tableComment(String tableName) {
List<String> values = jdbcTemplate.query("""
SELECT comments
FROM all_tab_comments
WHERE owner = ?
AND table_name = ?
""", (rs, rowNum) -> rs.getString(1), OWNER, tableName);
return values.isEmpty() ? "" : values.getFirst();
}
private List<SchemaMetadataColumn> columns(
String tableName,
Map<String, List<SchemaAnnotation>> annotations
) {
return jdbcTemplate.query("""
SELECT c.column_name,
CASE
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
THEN c.data_type || '(' || c.char_length || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ')'
ELSE c.data_type
END AS display_type,
c.nullable,
cc.comments
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner
AND cc.table_name = c.table_name
AND cc.column_name = c.column_name
WHERE c.owner = ?
AND c.table_name = ?
ORDER BY c.column_id
""", (rs, rowNum) -> new SchemaMetadataColumn(
rs.getString("column_name"),
rs.getString("display_type"),
"Y".equalsIgnoreCase(rs.getString("nullable")),
nullToEmpty(rs.getString("comments")),
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
), OWNER, tableName);
}
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
jdbcTemplate.query("""
SELECT column_name, annotation_name, annotation_value
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
""", rs -> {
String target = rs.getString("column_name") == null
? tableTargetKey()
: columnTargetKey(rs.getString("column_name"));
grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
.add(nullToEmpty(rs.getString("annotation_value")));
}, tableName);
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> {
List<SchemaAnnotation> annotations = new ArrayList<>();
valuesByName.forEach((name, values) -> annotations.add(new SchemaAnnotation(
name,
String.join("\n--- duplicate annotation value ---\n", values)
)));
result.put(target, annotations);
});
return result;
}
private boolean annotationExists(String tableName, String columnName, String annotationName) {
Integer count = columnName == null
? jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name IS NULL
""", Integer.class, tableName, annotationName)
: jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name = ?
""", Integer.class, tableName, annotationName, columnName);
return count != null && count > 0;
}
private String annotationSql(String tableName, String columnName, String operation) {
if (columnName == null) {
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
}
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
+ " ANNOTATIONS (" + operation + ")";
}
private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name");
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = ?
AND table_name = ?
AND column_name = ?
""", Integer.class, OWNER, tableName, column);
if (count == null || count == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
return column;
}
private String requireSimpleName(String value, String label) {
if (value == null || value.isBlank()) {
throw new AppException(label + "은(는) 필수입니다.");
}
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) {
throw new AppException(label + " 형식이 올바르지 않습니다. 영문 대문자, 숫자, _, $, #만 사용할 수 있습니다.");
}
return normalized;
}
private String normalizeText(String value, int maxLength, String label) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() > maxLength) {
throw new AppException(label + "은(는) " + maxLength + "자 이하여야 합니다.");
}
return normalized;
}
private String qualifiedTable(String tableName) {
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
}
private String quoteName(String value) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private String quoteLiteral(String value) {
return "'" + value.replace("'", "''") + "'";
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private String tableTargetKey() {
return "<TABLE>";
}
private String columnTargetKey(String columnName) {
return requireSimpleName(columnName, "column name");
}
}

View File

@@ -0,0 +1,136 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptExplanation;
import java.util.stream.IntStream;
import org.springframework.stereotype.Service;
/**
* Sends a selected, read-only SQL source to the configured LLM for explanation.
* The source comes from SecuritySqlScriptService's fixed catalogue; neither a
* request value nor an LLM response can modify or execute database SQL.
*/
@Service
public class SecuritySqlScriptExplanationService {
private final SecuritySqlScriptService scriptService;
private final OciGenerativeAiChatClient aiClient;
public SecuritySqlScriptExplanationService(
SecuritySqlScriptService scriptService,
OciGenerativeAiChatClient aiClient
) {
this.scriptService = scriptService;
this.aiClient = aiClient;
}
public SecuritySqlScriptExplanation explain(String scriptId) {
SecuritySqlScript script = scriptService.find(scriptId);
String prompt = buildPrompt(script);
if (!aiClient.configured()) {
return new SecuritySqlScriptExplanation(
"AI_NOT_CONFIGURED",
aiClient.modelName(),
"OCI AI 호출 설정이 없어 설명을 생성하지 않았습니다. 누락 또는 불일치: "
+ aiClient.configurationHint(),
prompt,
script
);
}
try {
return new SecuritySqlScriptExplanation(
"SUCCESS",
aiClient.modelName(),
aiClient.chat(systemPrompt(), prompt),
prompt,
script
);
} catch (AppException exception) {
return new SecuritySqlScriptExplanation(
"AI_CALL_FAILED",
aiClient.modelName(),
exception.getMessage(),
prompt,
script
);
} catch (Exception exception) {
return new SecuritySqlScriptExplanation(
"AI_CALL_FAILED",
aiClient.modelName(),
"AI 설명 호출에 실패했습니다. SQL 원문은 변경되지 않았으며, 잠시 후 다시 시도하세요.",
prompt,
script
);
}
}
private String systemPrompt() {
return """
당신은 Oracle 보안 운영 SQL을 검토하는 선임 데이터베이스 보안 엔지니어다.
제공된 source만 근거로 한국어 Markdown 설명을 작성한다. SQL을 실행·수정·제안된 명령으로 바꾸지 않는다.
source에 없는 객체·권한·실행 결과를 추측하지 않는다. 비밀값을 요청하거나 출력하지 않는다.
최종 응답은 반드시 explanation 키 하나에 Markdown 문자열을 담은 JSON 객체로 반환한다.
""";
}
private String buildPrompt(SecuritySqlScript script) {
return """
다음은 Git 형상에 저장된 Oracle 보안 SQL 스크립트다. 운영자가 코드를 이해할 수 있도록 전체 설명과 부분별 주석을 작성한다.
[스크립트 메타데이터]
- 분류: %s
- 파일: %s
- 제목: %s
- 용도: %s
[줄 번호가 붙은 SQL 원문]
%s
[출력 형식]
## 전체 설명
- 이 스크립트가 만드는/변경하는 DB 객체와 목적을 5줄 이내로 설명한다.
- 실행 전제조건, 실행 사용자, 다른 스크립트와의 순서가 source에 있으면 명시한다.
## 실행 흐름
- source의 실제 실행 순서를 번호 목록으로 정리한다.
## 블록별 주석
- 주석 heading, PROMPT, CREATE/ALTER/MERGE/GRANT/DECLARE/BEGIN, PACKAGE, PROCEDURE, FUNCTION 단위로 블록을 나눈다.
- 각 블록은 반드시 `### [L시작-L끝] 블록명` 제목으로 시작한다.
- 각 블록에서 “무엇을 하는지”, “입력/참조 객체”, “보안·VPD·ASO 영향”, “실패/주의점”을 source 근거가 있는 범위에서 bullet로 적는다.
## 토큰 처리·사용자 적용 흐름
- source에 Bearer/Authorization/auth_header/token/key 또는 token을 받아 context를 설정하는 코드가 있으면, 반드시 “입력 → 검증/조회 → CB_AGENT_CTX 등 context 설정 → VPD/ASO/Select AI 적용 → 실패 시 동작” 순서로 설명한다.
- 각 단계에는 source line range와 실제 식별자(예: auth_header, set_vpd_context, SYS_CONTEXT)를 붙인다.
- source가 토큰을 직접 다루지 않는 메타데이터/초기값 스크립트라면 “이 스크립트는 토큰을 직접 검증하거나 사용자별 접근을 판정하지 않는다”라고 명시하고, source 주석/호출 관계에서 확인되는 다음 런타임 단계를 설명한다.
- VPD는 행 접근, ASO/DBMS_REDACT는 컬럼 표시 보호라는 점을 source 근거가 있는 범위에서 구분한다.
- 실제 어떤 사용자가 어떤 행·원문 컬럼을 볼지는 토큰으로 설정된 DB context와 당시 권한 데이터에 따라 확정된다고 구분한다.
- source에 없는 역할명, 사용자명, 권한 결과는 만들지 않는다.
## 운영 확인 포인트
- source에서 직접 확인 가능한 DB 객체·권한·정책·ORDS endpoint를 최대 7개로 정리한다.
## 판단 한계
- source만으로 확인할 수 없는 실행 결과나 권한 효과가 있으면 명시한다.
[엄격한 규칙]
- Markdown으로 120줄 이내에 작성한다.
- 줄 번호와 SQL 객체명은 제공된 source와 일치해야 한다.
- 일반론이나 추측은 쓰지 않는다.
""".formatted(
script.category(),
script.fileName(),
script.title(),
script.description(),
numberedSource(script.source())
);
}
private String numberedSource(String source) {
String[] lines = source.split("\\R", -1);
return IntStream.range(0, lines.length)
.mapToObj(index -> "%4d | %s".formatted(index + 1, lines[index]))
.reduce((left, right) -> left + "\n" + right)
.orElse("");
}
}

View File

@@ -0,0 +1,102 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
/**
* Read-only catalogue of security deployment SQL bundled from the Git-tracked
* sql/adb directory. Script ids are an application whitelist: request input
* never becomes a filesystem or classpath path.
*/
@Service
public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
new ScriptDefinition(
"aso-masking-metadata",
"ASO / 마스킹",
"62_kb_aso_masking_backoffice_metadata.sql",
"컬럼 마스킹 규칙 메타데이터",
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
),
new ScriptDefinition(
"aso-masking-runtime",
"ASO / 마스킹",
"63_kb_aso_masking_rule_runtime.sql",
"ASO 마스킹 런타임 적용",
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
),
new ScriptDefinition(
"aso-masking-default-columns",
"ASO / 마스킹",
"64_kb_aso_masking_default_column_rules.sql",
"ASO 기본 대상 컬럼",
"주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다."
),
new ScriptDefinition(
"select-ai-vpd-api",
"Select AI / 행 접근",
"65_kb_select_ai_vpd_query_api.sql",
"행 접근 적용 Select AI 조회 API",
"생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다."
),
new ScriptDefinition(
"select-ai-vpd-ords",
"ORDS / Select AI",
"66_kb_select_ai_vpd_query_ords.sql",
"Select AI 행 접근 ORDS Endpoint",
"Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다."
)
);
public List<SecuritySqlScriptSummary> list() {
return CURATED_SCRIPTS.stream()
.map(definition -> new SecuritySqlScriptSummary(
definition.scriptId(),
definition.category(),
definition.fileName(),
definition.title(),
definition.description()
))
.toList();
}
public SecuritySqlScript find(String scriptId) {
ScriptDefinition definition = CURATED_SCRIPTS.stream()
.filter(candidate -> candidate.scriptId().equals(scriptId))
.findFirst()
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
return new SecuritySqlScript(
definition.scriptId(),
definition.category(),
definition.fileName(),
definition.title(),
definition.description(),
readSource(definition.fileName())
);
}
private String readSource(String fileName) {
ClassPathResource resource = new ClassPathResource("sql/adb/" + fileName);
try (InputStream input = resource.getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new AppException("배포된 보안 SQL 스크립트를 읽을 수 없습니다: " + fileName);
}
}
private record ScriptDefinition(
String scriptId,
String category,
String fileName,
String title,
String description
) {
}
}

View File

@@ -5,24 +5,25 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/** Calls the ORDS boundary; the database endpoint owns bearer-to-VPD-context mapping. */
/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
@Service
public class SelectAiAgentOrdsService {
static final String ORDS_PATH = "/cb-ords/kb-select-ai-agent/run";
private static final int MAX_PROMPT_LENGTH = 8_000;
static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_LIMIT = 100;
private final SettingService settingService;
private final RestTemplate restTemplate;
@@ -30,21 +31,29 @@ public class SelectAiAgentOrdsService {
public SelectAiAgentOrdsService(
SettingService settingService,
@Qualifier("ordsAgentRestTemplate") RestTemplate restTemplate,
RestTemplate ordsAgentRestTemplate,
ObjectMapper objectMapper
) {
this.settingService = settingService;
this.restTemplate = restTemplate;
this.restTemplate = ordsAgentRestTemplate;
this.objectMapper = objectMapper;
}
/**
* Compatibility overload for callers of the former SQL-generation tool.
* The row-access query endpoint is stateless and therefore ignores conversationId.
*/
public JsonNode run(String bearerToken, String prompt, String conversationId) {
return run(bearerToken, prompt, 50);
}
public JsonNode run(String bearerToken, String prompt, int limit) {
String normalizedToken = required(bearerToken, "bearerToken");
String normalizedPrompt = required(prompt, "prompt");
if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
String normalizedConversationId = normalizeConversationId(conversationId);
int normalizedLimit = normalizeLimit(limit);
String baseUrl = settingService.ordsBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
throw new AppException("ORDS base URL이 설정되지 않았습니다.");
@@ -52,9 +61,7 @@ public class SelectAiAgentOrdsService {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("prompt", normalizedPrompt);
if (normalizedConversationId != null) {
requestBody.put("conversationId", normalizedConversationId);
}
requestBody.put("limit", normalizedLimit);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(normalizedToken);
@@ -70,14 +77,18 @@ public class SelectAiAgentOrdsService {
);
JsonNode body = parse(response.getBody());
if (body.hasNonNull("error")) {
throw new AppException("Select AI Agent ORDS 오류: " + body.path("error").asText());
throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
}
return body;
} catch (HttpStatusCodeException e) {
throw new AppException("Select AI Agent ORDS HTTP " + e.getStatusCode().value()
if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
|| e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
throw new VpdTokenAccessDeniedException();
}
throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
+ ": " + responseError(e.getResponseBodyAsString()));
} catch (ResourceAccessException e) {
throw new AppException("Select AI Agent ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
}
}
@@ -91,13 +102,13 @@ public class SelectAiAgentOrdsService {
private JsonNode parse(String value) {
try {
if (value == null || value.isBlank()) {
throw new AppException("Select AI Agent ORDS 응답 본문이 비어 있습니다.");
throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
}
return objectMapper.readTree(value);
} catch (AppException e) {
throw e;
} catch (Exception e) {
throw new AppException("Select AI Agent ORDS 응답 JSON 파싱 실패: " + e.getMessage());
throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
}
}
@@ -117,14 +128,10 @@ public class SelectAiAgentOrdsService {
return value.trim();
}
private String normalizeConversationId(String value) {
if (value == null || value.isBlank()) {
return null;
private int normalizeLimit(int value) {
if (value < 1) {
return 50;
}
String normalized = value.trim();
if (!normalized.matches("[A-Za-z0-9._:-]{1,128}")) {
throw new AppException("conversationId는 영문/숫자/._:-만 사용하고 128자 이하여야 합니다.");
}
return normalized;
return Math.min(value, MAX_LIMIT);
}
}

View File

@@ -60,10 +60,27 @@ public class StructuredDataService {
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM " + OWNER + "." + table.tableName() + " WHERE ROWNUM <= ?", ROW_LIMIT);
previewSql(table), ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
} catch (DataAccessException exception) {
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요.");
}
}
/**
* The table is selected from a closed application whitelist, so the query
* text remains fixed and no request value can become a SQL identifier.
*/
private String previewSql(StructuredDataTable table) {
return switch (table.key()) {
case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?";
case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?";
case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?";
case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?";
case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?";
case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?";
case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?";
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
};
}
}

View File

@@ -15,21 +15,21 @@ public class UserService {
private final UserMapper userMapper;
private final AuditService auditService;
private final DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier;
private final ExternalAuthorizationChangeNotifier authorizationChangeNotifier;
@Autowired
public UserService(
UserMapper userMapper,
AuditService auditService,
DdsAuthorizationChangeNotifier ddsAuthorizationChangeNotifier
ExternalAuthorizationChangeNotifier authorizationChangeNotifier
) {
this.userMapper = userMapper;
this.auditService = auditService;
this.ddsAuthorizationChangeNotifier = ddsAuthorizationChangeNotifier;
this.authorizationChangeNotifier = authorizationChangeNotifier;
}
public UserService(UserMapper userMapper, AuditService auditService) {
this(userMapper, auditService, DdsAuthorizationChangeNotifier.noop());
this(userMapper, auditService, ExternalAuthorizationChangeNotifier.noop());
}
public List<AppUser> findAll() {
@@ -45,7 +45,7 @@ public class UserService {
long userId = userMapper.nextUserId();
userMapper.insertUser(userId, command);
auditService.record(new AuditEvent("USER_CREATED", null, null, "SUCCESS", null, null, command.username()));
ddsAuthorizationChangeNotifier.changed("USER_CREATED");
authorizationChangeNotifier.changed("USER_CREATED");
}
@Transactional
@@ -56,7 +56,7 @@ public class UserService {
}
auditService.record(new AuditEvent("USER_ACTIVE_CHANGED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",active=" + active));
ddsAuthorizationChangeNotifier.changed("USER_ACTIVE_CHANGED");
authorizationChangeNotifier.changed("USER_ACTIVE_CHANGED");
}
@Transactional
@@ -64,7 +64,7 @@ public class UserService {
userMapper.insertUserRole(userId, roleId);
auditService.record(new AuditEvent("USER_ROLE_GRANTED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("USER_ROLE_GRANTED");
authorizationChangeNotifier.changed("USER_ROLE_GRANTED");
}
@Transactional
@@ -75,6 +75,6 @@ public class UserService {
}
auditService.record(new AuditEvent("USER_ROLE_REVOKED", null, null, "SUCCESS", null, null,
"userId=" + userId + ",roleId=" + roleId));
ddsAuthorizationChangeNotifier.changed("USER_ROLE_REVOKED");
authorizationChangeNotifier.changed("USER_ROLE_REVOKED");
}
}

View File

@@ -1,6 +1,7 @@
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;
@@ -20,6 +21,7 @@ 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;
@@ -31,9 +33,54 @@ import org.springframework.transaction.annotation.Transactional;
public class VpdPolicyService {
private static final Set<String> ALLOWED_STATEMENTS = Set.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX");
private static final long CATALOG_CACHE_MILLIS = 60_000L;
// 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*;"
);
@@ -42,7 +89,8 @@ public class VpdPolicyService {
private final JdbcTemplate jdbcTemplate;
private final OpenAiCompatibleClient aiClient;
private final Map<String, CacheEntry<List<VpdTargetView>>> vpdTargetsCache = new ConcurrentHashMap<>();
private volatile CacheEntry<VpdPolicyFormOptions> formOptionsCache;
private final AtomicReference<CacheEntry<VpdPolicyFormOptions>> formOptionsCache =
new AtomicReference<>();
public VpdPolicyService(VpdPolicyMapper mapper, JdbcTemplate jdbcTemplate, OpenAiCompatibleClient aiClient) {
this.mapper = mapper;
@@ -71,7 +119,7 @@ public class VpdPolicyService {
}
public VpdPolicyFormOptions formOptions() {
CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache;
CacheEntry<VpdPolicyFormOptions> cached = formOptionsCache.get();
if (cached != null && !cached.expired()) {
return cached.value();
}
@@ -84,7 +132,7 @@ public class VpdPolicyService {
buildPolicyTemplateOptions(functions, mapper.findPolicyTemplateOptions()),
List.of("SELECT", "INSERT", "UPDATE", "DELETE", "INDEX")
);
formOptionsCache = new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS);
formOptionsCache.set(new CacheEntry<>(options, System.currentTimeMillis() + CATALOG_CACHE_MILLIS));
return options;
}
@@ -108,7 +156,7 @@ public class VpdPolicyService {
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()
@@ -292,7 +340,7 @@ public class VpdPolicyService {
description = null;
}
return description == null || description.isBlank()
? objectOwner + "." + objectName + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 " + policyName + " policy입니다."
? objectOwner + "." + objectName + "에 요청마다 현재 행 접근 규칙의 조건을 적용하는 " + policyName + " policy입니다."
: description;
}
@@ -310,6 +358,18 @@ public class VpdPolicyService {
: 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
@@ -414,7 +474,17 @@ public class VpdPolicyService {
public void clearCatalogCache() {
vpdTargetsCache.clear();
formOptionsCache = null;
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(
@@ -453,21 +523,7 @@ public class VpdPolicyService {
boolean enabled,
boolean updateCheck
) {
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(updateCheck ? "TRUE" : "FALSE", enabled ? "TRUE" : "FALSE"),
jdbcTemplate.update(addPolicySql(enabled, updateCheck),
objectOwner,
objectName,
policyName,
@@ -476,6 +532,17 @@ public class VpdPolicyService {
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();
@@ -538,7 +605,7 @@ public class VpdPolicyService {
## 요약
- 이 policy가 무엇을 허용/차단하는지 3줄 이내로 먼저 설명한다.
- fail-closed 조건이 있으면 요약에 포함한다.
- 컬럼 마스킹/NULL 처리 판단 가능 여부를 요약에 포함한다.
- 컬럼 마스킹은 ASO/Data Redaction 별도 정책에서 판단한다. 이 VPD source만으로 알 수 있는 행 접근 범위와, 컬럼 마스킹 판단 가능 여부를 구분한다.
## 상세
@@ -557,7 +624,7 @@ public class VpdPolicyService {
### 4. 실제 접근 결과 해석
- 이 policy가 행(row)을 허용하는 조건과 제외하는 조건을 구분한다.
- 컬럼 마스킹/NULL 처리는 source에 직접 있지 않으면 "이 policy source만으로는 판단 불가"라고 쓴다.
- 컬럼 마스킹은 ASO/Data Redaction 별도 정책이다. source에 직접 있지 않으면 " VPD policy source만으로는 컬럼 마스킹 판단 불가"라고 쓴다.
### 5. 운영 확인 포인트
- 운영자가 DB에서 확인할 테이블/컬럼/컨텍스트 값을 5개 이하로 적는다.
@@ -628,6 +695,14 @@ public class VpdPolicyService {
}
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,

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.service;
/** Raised when ORDS rejects a missing, invalid, expired, or unauthorized user bearer token. */
public class VpdTokenAccessDeniedException extends AppException {
public VpdTokenAccessDeniedException() {
super("사용자 Bearer Token이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
}
}