Consolidate data access control backoffice updates
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** Optional bridge implemented by an external authorization runtime. */
|
||||
public interface ExternalAuthorizationSynchronizer {
|
||||
|
||||
void synchronize(String reason);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "건";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 권한 검증 보조자입니다.
|
||||
백오피스가 제공한 도구 실행 증거만 근거로 판단하고, 토큰 원문이나 비밀 값을 재출력하지 마세요.
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user