Files
vpd-permission-poc/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsMetadataService.java

361 lines
14 KiB
Java

package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerUpdateCommand;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerView;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsObjectHandlerResult;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.OrdsMetadataMapper;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.annotation.PreDestroy;
import java.util.List;
import java.util.Locale;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrdsMetadataService {
private static final String VECTOR_SEARCH_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
private static final Logger log = LoggerFactory.getLogger(OrdsMetadataService.class);
private final OrdsMetadataMapper mapper;
private final JdbcTemplate jdbcTemplate;
private final JdbcTemplate ordsMetadataJdbcTemplate;
private final HikariDataSource ordsMetadataDataSource;
private final ProtectedObjectService protectedObjectService;
public OrdsMetadataService(
OrdsMetadataMapper mapper,
JdbcTemplate jdbcTemplate,
ProtectedObjectService protectedObjectService,
DataSource dataSource,
@Value("${backoffice.ords.metadata-db.url:}") String ordsMetadataUrl,
@Value("${backoffice.ords.metadata-db.username:}") String ordsMetadataUsername,
@Value("${backoffice.ords.metadata-db.password:}") String ordsMetadataPassword
) {
this.mapper = mapper;
this.jdbcTemplate = jdbcTemplate;
this.protectedObjectService = protectedObjectService;
HikariDataSource metadataDataSource = null;
JdbcTemplate metadataJdbcTemplate = new JdbcTemplate(dataSource);
if (hasText(ordsMetadataUrl) && hasText(ordsMetadataUsername)) {
HikariConfig config = new HikariConfig();
config.setPoolName("vpd-ords-metadata-pool");
config.setJdbcUrl(ordsMetadataUrl.trim());
config.setUsername(ordsMetadataUsername.trim());
config.setPassword(ordsMetadataPassword == null ? "" : ordsMetadataPassword);
config.setDriverClassName("oracle.jdbc.OracleDriver");
config.setMaximumPoolSize(2);
config.setMinimumIdle(0);
config.setConnectionTimeout(10000);
// ORDS metadata is an optional administrative connection. Do not fail
// the whole backoffice startup when the remote TNS host is temporarily
// unavailable; Handler create/update will report the connection error
// at the point of use instead.
config.setInitializationFailTimeout(-1);
try {
metadataDataSource = new HikariDataSource(config);
metadataJdbcTemplate = new JdbcTemplate(metadataDataSource);
} catch (RuntimeException exception) {
log.warn("ORDS metadata DB is unavailable; using the main DB for read-only fallback: {}",
exception.getMessage());
if (metadataDataSource != null) {
metadataDataSource.close();
}
}
}
this.ordsMetadataDataSource = metadataDataSource;
this.ordsMetadataJdbcTemplate = metadataJdbcTemplate;
}
public List<OrdsHandlerView> findHandlers() {
String packageSource = mapper.findHandlerPackageSource();
return mapper.findHandlers().stream()
.map(handler -> new OrdsHandlerView(
handler.handlerId(),
handler.schemaName(),
handler.parsingSchema(),
handler.moduleName(),
handler.basePath(),
handler.template(),
handler.method(),
handler.sourceType(),
handler.source(),
handler.parameters(),
packageSource
))
.toList();
}
public String objectQueryHandlerSource(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
rejectGenericVectorHandler(object);
List<String> columns = protectedObjectService.findColumns(objectId).stream()
.map(ProtectedColumn::columnName)
.toList();
if (columns.isEmpty()) {
throw new AppException("보호 객체 컬럼이 없어 ORDS 조회 Handler Source를 만들 수 없습니다.");
}
requireIdentifier(object.owner(), "owner");
requireIdentifier(object.objectName(), "objectName");
columns.forEach(column -> requireIdentifier(column, "column"));
return objectQuerySource(object, columns);
}
@Transactional
public void updateHandlerSource(OrdsHandlerUpdateCommand command) {
if (command.source() == null || command.source().isBlank()) {
throw new AppException("ORDS Handler Source는 필수입니다.");
}
OrdsHandlerView handler = mapper.findHandler(command.handlerId());
if (handler == null) {
throw new AppException("수정할 ORDS Handler를 찾을 수 없습니다.");
}
String sourceType = handler.sourceType() == null ? "" : handler.sourceType().toLowerCase(Locale.ROOT);
if (!sourceType.contains("plsql") && !sourceType.contains("query")) {
throw new AppException("현재는 PL/SQL/SQL Query ORDS Handler만 수정할 수 있습니다: " + handler.sourceType());
}
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
if (currentUser == null || !currentUser.equalsIgnoreCase(handler.parsingSchema())) {
throw new AppException("ORDS Handler 수정은 parsing schema로 DB에 연결했을 때만 가능합니다. 현재 연결 사용자: "
+ currentUser + ", Handler parsing schema: " + handler.parsingSchema());
}
jdbcTemplate.update("""
DECLARE
v_source_type VARCHAR2(30);
BEGIN
IF ? = 'QUERY' THEN
v_source_type := ORDS.source_type_query;
ELSE
v_source_type := ORDS.source_type_plsql;
END IF;
ORDS.DEFINE_HANDLER(
p_module_name => ?,
p_pattern => ?,
p_method => ?,
p_source_type => v_source_type,
p_source => ?,
p_items_per_page => 0
);
COMMIT;
END;
""",
handlerSourceTypeCode(handler.sourceType()),
handler.moduleName(),
handler.template(),
handler.method(),
command.source());
}
@Transactional
public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
rejectGenericVectorHandler(object);
List<String> columns = protectedObjectService.findColumns(objectId).stream()
.map(ProtectedColumn::columnName)
.toList();
if (columns.isEmpty()) {
throw new AppException("보호 객체 컬럼이 없어 ORDS 조회 Handler를 만들 수 없습니다.");
}
requireIdentifier(object.owner(), "owner");
requireIdentifier(object.objectName(), "objectName");
columns.forEach(column -> requireIdentifier(column, "column"));
String currentUser = ordsMetadataJdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
if (currentUser == null || !currentUser.equalsIgnoreCase("CB_ORDS")) {
throw new AppException("ORDS 조회 Handler 생성은 ORDS parsing schema(CB_ORDS)로 DB에 연결했을 때만 가능합니다. 현재 연결 사용자: "
+ currentUser + ". 현재 연결에서는 소스 보기를 눌러 기본 PL/SQL을 확인한 뒤 ORDS Handler에 적용하세요.");
}
String moduleName = "cb.object.query";
String basePath = "cb-object-query/";
String template = object.owner().toLowerCase(Locale.ROOT) + "/" + object.objectName().toLowerCase(Locale.ROOT);
String ordsPath = "cb-ords/" + basePath + template;
String source = objectQueryHandlerSource(objectId);
ordsMetadataJdbcTemplate.update("""
DECLARE
v_module_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_module_count
FROM user_ords_modules
WHERE name = ?;
IF v_module_count = 0 THEN
ORDS.DEFINE_MODULE(
p_module_name => ?,
p_base_path => ?,
p_items_per_page => 25,
p_status => 'PUBLISHED'
);
END IF;
ORDS.DEFINE_TEMPLATE(
p_module_name => ?,
p_pattern => ?
);
ORDS.DEFINE_HANDLER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_source_type => ORDS.source_type_plsql,
p_source => ?,
p_items_per_page => 0
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_name => 'Authorization',
p_bind_variable_name => 'auth_header',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_name => 'X-VPD-Probe-Id',
p_bind_variable_name => 'probe_id',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_name => 'limit',
p_bind_variable_name => 'row_limit',
p_source_type => 'URI',
p_param_type => 'INT',
p_access_method => 'IN'
);
COMMIT;
END;
""",
moduleName,
moduleName, basePath,
moduleName, template,
moduleName, template, source,
moduleName, template,
moduleName, template,
moduleName, template);
protectedObjectService.updateOrdsPath(object.objectId(), ordsPath);
return new OrdsObjectHandlerResult(object.objectId(), ordsPath, moduleName, template);
}
private String objectQuerySource(ProtectedObject object, List<String> columns) {
String selectColumns = columns.stream()
.map(column -> "o." + column)
.reduce((left, right) -> left + ",\n " + right)
.orElseThrow();
String traceSelectColumns = columns.stream()
.map(column -> "o." + column)
.reduce((left, right) -> left + ", " + right)
.orElseThrow();
return """
DECLARE
v_rows SYS_REFCURSOR;
v_vpd_predicate VARCHAR2(32767);
v_effective_sql VARCHAR2(32767);
BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
-- This is the same predicate function invoked by DBMS_RLS for the
-- SELECT below. It is returned only as diagnostic metadata.
BEGIN
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('%s', '%s');
v_effective_sql := 'SELECT %s FROM %s.%s o WHERE (' || v_vpd_predicate
|| ') AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)';
EXCEPTION
WHEN OTHERS THEN
v_vpd_predicate := NULL;
v_effective_sql := NULL;
END;
OPEN v_rows FOR
SELECT %s
FROM %s.%s o
WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500);
:status_code := 200;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
IF v_effective_sql IS NOT NULL THEN
APEX_JSON.WRITE('vpd_predicate', v_vpd_predicate);
APEX_JSON.WRITE('effective_sql', v_effective_sql);
END IF;
APEX_JSON.WRITE('items', v_rows);
APEX_JSON.CLOSE_OBJECT;
cb_ords_handler_pkg.clear_vpd_context;
EXCEPTION
WHEN OTHERS THEN
cb_ords_handler_pkg.clear_vpd_context;
:status_code := 403;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('error', SQLERRM);
APEX_JSON.CLOSE_OBJECT;
END;
""".formatted(
object.owner(),
object.objectName(),
traceSelectColumns,
object.owner(),
object.objectName(),
selectColumns,
object.owner(),
object.objectName());
}
private void rejectGenericVectorHandler(ProtectedObject object) {
if (VECTOR_SEARCH_OBJECT.equalsIgnoreCase(object.objectName())) {
throw new AppException(
"벡터 검색 객체는 일반 Handler로 등록할 수 없습니다. "
+ "29_agent_ords_vector_search_ords.sql의 전용 Handler를 사용하세요(EMBEDDING 미노출).");
}
}
private String handlerSourceTypeCode(String sourceType) {
String normalized = sourceType == null ? "" : sourceType.toLowerCase(Locale.ROOT);
if (normalized.contains("query")) {
return "QUERY";
}
return "PLSQL";
}
private void requireIdentifier(String value, String label) {
if (value == null || !value.matches("[A-Z][A-Z0-9_$#]*")) {
throw new AppException("ORDS Handler 생성에 사용할 수 없는 " + label + " 식별자입니다: " + value);
}
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
@PreDestroy
public void closeOrdsMetadataDataSource() {
if (ordsMetadataDataSource != null) {
ordsMetadataDataSource.close();
}
}
}