fix #495: add schema preflight and DDL details

This commit is contained in:
devmrko
2026-06-26 16:32:38 +09:00
parent cacf7b2bc4
commit 5d864379bd
8 changed files with 922 additions and 182 deletions

View File

@@ -2,9 +2,12 @@ package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.schema.SchemaActionResult;
import com.cloudhandson.vpdbackoffice.domain.schema.SchemaPreflightCheck;
import com.cloudhandson.vpdbackoffice.domain.schema.SchemaPreflightView;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -12,6 +15,187 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class BackofficeSchemaService {
private static final String ORDS_SCHEMA = "CB_ORDS";
private static final List<SequenceDefinition> SEQUENCES = List.of(
new SequenceDefinition("CB_AGENT_BEARER_KEY_SEQ", 1000),
new SequenceDefinition("CB_PERMISSION_SEQ", 1000),
new SequenceDefinition("CB_PERMISSION_RULE_SEQ", 10000),
new SequenceDefinition("CB_ORDS_PROBE_AUDIT_SEQ", 1)
);
private static final List<TableDefinition> TABLES = List.of(
new TableDefinition("CB_APP_USER", """
CREATE TABLE cb_app_user (
user_id NUMBER PRIMARY KEY,
user_name VARCHAR2(100) NOT NULL UNIQUE,
employee_no VARCHAR2(50),
dept_code VARCHAR2(50),
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
)
"""),
new TableDefinition("CB_APP_ROLE", """
CREATE TABLE cb_app_role (
role_id NUMBER PRIMARY KEY,
role_name VARCHAR2(100) NOT NULL UNIQUE,
max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL
)
"""),
new TableDefinition("CB_APP_GROUP", """
CREATE TABLE cb_app_group (
group_id NUMBER PRIMARY KEY,
group_code VARCHAR2(100) NOT NULL UNIQUE,
group_name VARCHAR2(100) NOT NULL,
description VARCHAR2(200),
active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL
)
"""),
new TableDefinition("CB_USER_ROLE", """
CREATE TABLE cb_user_role (
user_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_user_role_pk PRIMARY KEY (user_id, role_id)
)
"""),
new TableDefinition("CB_USER_GROUP", """
CREATE TABLE cb_user_group (
group_id NUMBER NOT NULL,
user_id NUMBER NOT NULL,
CONSTRAINT cb_user_group_pk PRIMARY KEY (group_id, user_id)
)
"""),
new TableDefinition("CB_GROUP_ROLE", """
CREATE TABLE cb_group_role (
group_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_group_role_pk PRIMARY KEY (group_id, role_id)
)
"""),
new TableDefinition("CB_PERMISSION", """
CREATE TABLE cb_permission (
perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL,
target_name VARCHAR2(128) NOT NULL,
action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL,
permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL
)
"""),
new TableDefinition("CB_PERMISSION_RULE", """
CREATE TABLE cb_permission_rule (
rule_id NUMBER PRIMARY KEY,
perm_id NUMBER NOT NULL,
rule_column VARCHAR2(128),
rule_type VARCHAR2(30) NOT NULL,
rule_value VARCHAR2(4000)
)
"""),
new TableDefinition("CB_PERMISSION_COLUMN", """
CREATE TABLE cb_permission_column (
permission_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
)
"""),
new TableDefinition("CB_AGENT_BEARER_KEY", """
CREATE TABLE cb_agent_bearer_key (
key_id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL,
key_prefix VARCHAR2(30) NOT NULL,
key_hash VARCHAR2(128) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
description VARCHAR2(200)
)
"""),
new TableDefinition("CB_PROTECTED_OBJECT", """
CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL UNIQUE,
ords_path VARCHAR2(300) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
)
"""),
new TableDefinition("CB_PROTECTED_COLUMN", """
CREATE TABLE cb_protected_column (
column_id NUMBER PRIMARY KEY,
object_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
visible_role_id NUMBER,
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL,
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
)
"""),
new TableDefinition("CB_ORDS_PROBE_AUDIT", """
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
event_type VARCHAR2(50) NOT NULL,
key_id NUMBER,
object_id NUMBER,
status VARCHAR2(50),
row_count NUMBER,
error_code VARCHAR2(100),
message VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""),
new TableDefinition("CB_BACKOFFICE_SETTING", """
CREATE TABLE cb_backoffice_setting (
setting_key VARCHAR2(100) PRIMARY KEY,
setting_value VARCHAR2(1000),
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
""")
);
private static final List<ColumnDefinition> COLUMNS = List.of(
new ColumnDefinition("CB_APP_ROLE", "MAX_SENSITIVITY_LEVEL",
"ALTER TABLE cb_app_role ADD (max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"),
new ColumnDefinition("CB_PERMISSION", "PERMISSION_EFFECT",
"ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)"),
new ColumnDefinition("CB_PERMISSION_RULE", "RULE_COLUMN",
"ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"),
new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION",
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"),
new ColumnDefinition("CB_PROTECTED_COLUMN", "SENSITIVITY_LEVEL",
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"),
new ColumnDefinition("CB_PROTECTED_COLUMN", "REDACTION_METHOD",
"ALTER TABLE cb_protected_column ADD (redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL)")
);
private static final List<ConstraintDefinition> CONSTRAINTS = List.of(
new ConstraintDefinition("CB_USER_ROLE_PK", "CB_USER_ROLE", "PK/INDEX"),
new ConstraintDefinition("CB_USER_GROUP_PK", "CB_USER_GROUP", "PK/INDEX"),
new ConstraintDefinition("CB_GROUP_ROLE_PK", "CB_GROUP_ROLE", "PK/INDEX"),
new ConstraintDefinition("CB_PERMISSION_COLUMN_PK", "CB_PERMISSION_COLUMN", "PK/INDEX"),
new ConstraintDefinition("CB_PROTECTED_COLUMN_UK", "CB_PROTECTED_COLUMN", "UK/INDEX")
);
private static final String PROTECTED_COLUMN_MIGRATION_SQL = """
UPDATE cb_protected_column
SET sensitivity_level = CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
redaction_method = CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
WHERE sensitivity_level = 'PUBLIC'
AND redaction_method = 'NONE'
AND sensitive_yn = 'Y'
""";
private static final String SETTINGS_MERGE_SQL = """
MERGE INTO cb_backoffice_setting dst
USING (
SELECT 'ORDS_BASE_URL' setting_key, ? setting_value
FROM dual
) src
ON (dst.setting_key = src.setting_key)
WHEN MATCHED THEN UPDATE SET dst.setting_value = src.setting_value, dst.updated_at = SYSTIMESTAMP
WHERE dst.setting_value IS NULL OR TRIM(dst.setting_value) IS NULL
WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at)
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP)
""";
private final JdbcTemplate jdbcTemplate;
private final BackofficeProperties properties;
@@ -20,212 +204,430 @@ public class BackofficeSchemaService {
this.properties = properties;
}
public SchemaPreflightView preflight() {
String currentUser = currentUser();
List<SchemaPreflightCheck> checks = new ArrayList<>();
for (SequenceDefinition sequence : SEQUENCES) {
addCountCheck(
checks,
"Backoffice Metadata",
sequence.name(),
"SEQUENCE",
"SELECT COUNT(*) FROM user_sequences WHERE sequence_name = ?",
"지원 테이블 생성/확인을 실행하면 생성됩니다.",
false,
sequence.name()
);
}
for (TableDefinition table : TABLES) {
addCountCheck(
checks,
"Backoffice Metadata",
table.name(),
"TABLE",
"SELECT COUNT(*) FROM user_tables WHERE table_name = ?",
"지원 테이블 생성/확인을 실행하면 생성됩니다.",
false,
table.name()
);
}
for (ColumnDefinition column : COLUMNS) {
addCountCheck(
checks,
"Backoffice Metadata",
column.table() + "." + column.column(),
"COLUMN",
"SELECT COUNT(*) FROM user_tab_columns WHERE table_name = ? AND column_name = ?",
"지원 테이블 생성/확인을 실행하면 보강됩니다.",
false,
column.table(),
column.column()
);
}
for (ConstraintDefinition constraint : CONSTRAINTS) {
addCountCheck(
checks,
"Constraint/Index",
constraint.name(),
constraint.objectType(),
"SELECT COUNT(*) FROM user_constraints WHERE constraint_name = ? AND table_name = ?",
"테이블을 재생성하거나 constraint 생성 SQL을 sqlcl로 확인하세요.",
false,
constraint.name(),
constraint.table()
);
}
addCountCheck(checks, "VPD Runtime", "CB_AGENT_CTX", "CONTEXT",
"SELECT COUNT(*) FROM all_context WHERE namespace = 'CB_AGENT_CTX'",
"sqlcl에서 17_agent_ords_security_local_vpd_setup.sql 또는 CREATE CONTEXT를 실행하세요.",
true);
addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CTX_PKG", "PACKAGE",
"sqlcl에서 cb_agent_ctx_pkg package를 생성/컴파일하세요.");
addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CTX_PKG BODY", "PACKAGE BODY",
"sqlcl에서 cb_agent_ctx_pkg package body를 생성/컴파일하세요.", "CB_AGENT_CTX_PKG");
addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_DOC_VPD_FILTER", "FUNCTION",
"sqlcl에서 26_agent_ords_security_dynamic_vpd_filter.sql을 실행하세요.");
addUserObjectCheck(checks, "VPD Runtime", "CB_AGENT_CAN_READ_COLUMN", "FUNCTION",
"sqlcl에서 backoffice support redaction function을 생성/컴파일하세요.");
addPolicyCheck(checks);
addCountCheck(checks, "ORDS Runtime", ORDS_SCHEMA, "USER",
"SELECT COUNT(*) FROM all_users WHERE username = ?",
"sqlcl 또는 DBA 계정으로 CB_ORDS 사용자를 생성하세요.",
true,
ORDS_SCHEMA);
addCountCheck(checks, "ORDS Runtime", "ORDS.ENABLE_SCHEMA(" + ORDS_SCHEMA + ")", "ORDS SCHEMA",
"SELECT COUNT(*) FROM dba_ords_schemas WHERE parsing_schema = ?",
"sqlcl에서 21_agent_ords_security_ords_enable_schema.sql을 실행하세요.",
true,
ORDS_SCHEMA);
addAllObjectCheck(checks, "ORDS Runtime", ORDS_SCHEMA, "CB_ORDS_HANDLER_PKG", "PACKAGE",
"CB_ORDS로 접속해 22_agent_ords_security_ords_handler_setup.sql을 실행하세요.");
addGrantCheck(checks, currentUser, "CB_AGENT_CTX_PKG", "EXECUTE");
addGrantCheck(checks, currentUser, "CB_AGENT_CAN_READ_COLUMN", "EXECUTE");
addProtectedObjectGrantCheck(checks);
return new SchemaPreflightView(currentUser, ORDS_SCHEMA, checks, appDdlPreview(), sqlclScript(currentUser));
}
@Transactional
public List<SchemaActionResult> initializeSchema() {
List<SchemaActionResult> results = new ArrayList<>();
createSequence(results, "cb_agent_bearer_key_seq", 1000);
createSequence(results, "cb_permission_seq", 1000);
createSequence(results, "cb_permission_rule_seq", 10000);
createSequence(results, "cb_ords_probe_audit_seq", 1);
createTable(results, "cb_app_user", """
CREATE TABLE cb_app_user (
user_id NUMBER PRIMARY KEY,
user_name VARCHAR2(100) NOT NULL UNIQUE,
employee_no VARCHAR2(50),
dept_code VARCHAR2(50),
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
)
""");
createTable(results, "cb_app_role", """
CREATE TABLE cb_app_role (
role_id NUMBER PRIMARY KEY,
role_name VARCHAR2(100) NOT NULL UNIQUE,
max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL
)
""");
addColumn(results, "cb_app_role", "max_sensitivity_level",
"ALTER TABLE cb_app_role ADD (max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)");
createTable(results, "cb_app_group", """
CREATE TABLE cb_app_group (
group_id NUMBER PRIMARY KEY,
group_code VARCHAR2(100) NOT NULL UNIQUE,
group_name VARCHAR2(100) NOT NULL,
description VARCHAR2(200),
active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL
)
""");
createTable(results, "cb_user_role", """
CREATE TABLE cb_user_role (
user_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_user_role_pk PRIMARY KEY (user_id, role_id)
)
""");
createTable(results, "cb_user_group", """
CREATE TABLE cb_user_group (
group_id NUMBER NOT NULL,
user_id NUMBER NOT NULL,
CONSTRAINT cb_user_group_pk PRIMARY KEY (group_id, user_id)
)
""");
createTable(results, "cb_group_role", """
CREATE TABLE cb_group_role (
group_id NUMBER NOT NULL,
role_id NUMBER NOT NULL,
CONSTRAINT cb_group_role_pk PRIMARY KEY (group_id, role_id)
)
""");
createTable(results, "cb_permission", """
CREATE TABLE cb_permission (
perm_id NUMBER PRIMARY KEY,
role_id NUMBER NOT NULL,
target_name VARCHAR2(128) NOT NULL,
action_name VARCHAR2(30) DEFAULT 'SELECT' NOT NULL,
permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL
)
""");
addColumn(results, "cb_permission", "permission_effect",
"ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)");
createTable(results, "cb_permission_rule", """
CREATE TABLE cb_permission_rule (
rule_id NUMBER PRIMARY KEY,
perm_id NUMBER NOT NULL,
rule_column VARCHAR2(128),
rule_type VARCHAR2(30) NOT NULL,
rule_value VARCHAR2(4000)
)
""");
addColumn(results, "cb_permission_rule", "rule_column", "ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))");
createTable(results, "cb_permission_column", """
CREATE TABLE cb_permission_column (
permission_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
CONSTRAINT cb_permission_column_pk PRIMARY KEY (permission_id, column_name)
)
""");
createTable(results, "cb_agent_bearer_key", """
CREATE TABLE cb_agent_bearer_key (
key_id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL,
key_prefix VARCHAR2(30) NOT NULL,
key_hash VARCHAR2(128) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
description VARCHAR2(200)
)
""");
addColumn(results, "cb_agent_bearer_key", "description",
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))");
createTable(results, "cb_protected_object", """
CREATE TABLE cb_protected_object (
object_id NUMBER PRIMARY KEY,
owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL UNIQUE,
ords_path VARCHAR2(300) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
)
""");
createTable(results, "cb_protected_column", """
CREATE TABLE cb_protected_column (
column_id NUMBER PRIMARY KEY,
object_id NUMBER NOT NULL,
column_name VARCHAR2(128) NOT NULL,
sensitive_yn CHAR(1) DEFAULT 'N' CHECK (sensitive_yn IN ('Y','N')) NOT NULL,
visible_role_id NUMBER,
sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL,
redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL,
CONSTRAINT cb_protected_column_uk UNIQUE (object_id, column_name)
)
""");
addColumn(results, "cb_protected_column", "sensitivity_level",
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)");
addColumn(results, "cb_protected_column", "redaction_method",
"ALTER TABLE cb_protected_column ADD (redaction_method VARCHAR2(20) DEFAULT 'NONE' NOT NULL)");
jdbcTemplate.update("""
UPDATE cb_protected_column
SET sensitivity_level = CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
redaction_method = CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
WHERE sensitivity_level = 'PUBLIC'
AND redaction_method = 'NONE'
AND sensitive_yn = 'Y'
""");
createTable(results, "cb_ords_probe_audit", """
CREATE TABLE cb_ords_probe_audit (
audit_id NUMBER PRIMARY KEY,
event_type VARCHAR2(50) NOT NULL,
key_id NUMBER,
object_id NUMBER,
status VARCHAR2(50),
row_count NUMBER,
error_code VARCHAR2(100),
message VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
""");
createTable(results, "cb_backoffice_setting", """
CREATE TABLE cb_backoffice_setting (
setting_key VARCHAR2(100) PRIMARY KEY,
setting_value VARCHAR2(1000),
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
""");
try {
currentUser();
} catch (DataAccessException exception) {
results.add(new SchemaActionResult("DB_CONNECTION", "PREFLIGHT", "FAILED",
"DB 연결을 확인할 수 없습니다. " + safeMessage(exception), "SELECT USER FROM dual"));
return results;
}
for (SequenceDefinition sequence : SEQUENCES) {
createSequence(results, sequence);
}
for (TableDefinition table : TABLES) {
createTable(results, table);
}
for (ColumnDefinition column : COLUMNS) {
addColumn(results, column);
}
runDml(results, "CB_PROTECTED_COLUMN", "DATA", PROTECTED_COLUMN_MIGRATION_SQL,
"민감 컬럼 legacy 값을 보강했습니다.", "UPDATED");
seedDefaultSettings(results);
return results;
}
private void createSequence(List<SchemaActionResult> results, String name, int startWith) {
runIgnoringAlreadyExists(results, name, "SEQUENCE",
"CREATE SEQUENCE " + name + " START WITH " + startWith + " INCREMENT BY 1 NOCACHE");
private void createSequence(List<SchemaActionResult> results, SequenceDefinition sequence) {
runIgnoringAlreadyExists(results, sequence.name(), "SEQUENCE", sequenceDdl(sequence));
}
private void createTable(List<SchemaActionResult> results, String name, String ddl) {
runIgnoringAlreadyExists(results, name, "TABLE", ddl);
private void createTable(List<SchemaActionResult> results, TableDefinition table) {
runIgnoringAlreadyExists(results, table.name(), "TABLE", table.ddl());
}
private void addColumn(List<SchemaActionResult> results, String table, String column, String ddl) {
private void addColumn(List<SchemaActionResult> results, ColumnDefinition column) {
try {
jdbcTemplate.execute(ddl);
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "CREATED", "컬럼을 추가했습니다."));
jdbcTemplate.execute(column.ddl());
results.add(new SchemaActionResult(column.displayName(), "COLUMN", "CREATED", "컬럼을 추가했습니다.", column.ddl()));
} catch (RuntimeException exception) {
if (oracleErrorCode(exception) == 1430) {
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "EXISTS", "이미 존재합니다."));
results.add(new SchemaActionResult(column.displayName(), "COLUMN", "EXISTS", "이미 존재합니다.", column.ddl()));
return;
}
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "FAILED", safeMessage(exception)));
throw exception;
results.add(new SchemaActionResult(column.displayName(), "COLUMN", "FAILED", safeMessage(exception), column.ddl()));
}
}
private void runIgnoringAlreadyExists(List<SchemaActionResult> results, String name, String action, String ddl) {
private void runIgnoringAlreadyExists(
List<SchemaActionResult> results,
String name,
String action,
String ddl
) {
try {
jdbcTemplate.execute(ddl);
results.add(new SchemaActionResult(name, action, "CREATED", "생성했습니다."));
results.add(new SchemaActionResult(name, action, "CREATED", "생성했습니다.", ddl));
} catch (RuntimeException exception) {
if (oracleErrorCode(exception) == 955) {
results.add(new SchemaActionResult(name, action, "EXISTS", "이미 존재합니다."));
results.add(new SchemaActionResult(name, action, "EXISTS", "이미 존재합니다.", ddl));
return;
}
results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception)));
throw exception;
results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception), ddl));
}
}
private void runDml(
List<SchemaActionResult> results,
String name,
String action,
String sql,
String message,
String status
) {
try {
int updated = jdbcTemplate.update(sql);
results.add(new SchemaActionResult(name, action, status, message + " (" + updated + " rows)", sql));
} catch (RuntimeException exception) {
results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception), sql));
}
}
private void seedDefaultSettings(List<SchemaActionResult> results) {
jdbcTemplate.update("""
MERGE INTO cb_backoffice_setting dst
USING (
SELECT 'ORDS_BASE_URL' setting_key, ? setting_value
FROM dual
) src
ON (dst.setting_key = src.setting_key)
WHEN MATCHED THEN UPDATE SET dst.setting_value = src.setting_value, dst.updated_at = SYSTIMESTAMP
WHERE dst.setting_value IS NULL OR TRIM(dst.setting_value) IS NULL
WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at)
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP)
""", properties.ords().baseUrl());
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED", "기본 ORDS URL 설정을 보강했습니다."));
try {
jdbcTemplate.update(SETTINGS_MERGE_SQL, properties.ords().baseUrl());
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED",
"기본 ORDS URL 설정을 보강했습니다.", SETTINGS_MERGE_SQL));
} catch (RuntimeException exception) {
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "FAILED",
safeMessage(exception), SETTINGS_MERGE_SQL));
}
}
private String currentUser() {
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
}
private void addCountCheck(
List<SchemaPreflightCheck> checks,
String layer,
String itemName,
String objectType,
String sql,
String missingAction,
boolean sqlclRequired,
Object... args
) {
try {
int count = count(sql, args);
checks.add(new SchemaPreflightCheck(
layer,
itemName,
objectType,
count > 0 ? "OK" : "MISSING",
count > 0 ? "존재합니다." : "찾을 수 없습니다.",
count > 0 ? "조치 없음" : missingAction,
sqlclRequired
));
} catch (DataAccessException exception) {
checks.add(new SchemaPreflightCheck(layer, itemName, objectType, "ERROR",
safeMessage(exception), missingAction, sqlclRequired));
}
}
private void addUserObjectCheck(
List<SchemaPreflightCheck> checks,
String layer,
String itemName,
String objectType,
String nextAction
) {
addUserObjectCheck(checks, layer, itemName, objectType, nextAction, itemName);
}
private void addUserObjectCheck(
List<SchemaPreflightCheck> checks,
String layer,
String itemName,
String objectType,
String nextAction,
String objectName
) {
try {
List<String> statuses = jdbcTemplate.queryForList("""
SELECT status
FROM user_objects
WHERE object_name = ?
AND object_type = ?
""", String.class, objectName, objectType);
if (statuses.isEmpty()) {
checks.add(new SchemaPreflightCheck(layer, itemName, objectType, "MISSING",
"찾을 수 없습니다.", nextAction, true));
return;
}
boolean valid = statuses.stream().allMatch(status -> "VALID".equalsIgnoreCase(status));
checks.add(new SchemaPreflightCheck(layer, itemName, objectType, valid ? "OK" : "WARN",
valid ? "VALID" : "INVALID 또는 컴파일 확인 필요",
valid ? "조치 없음" : nextAction,
true));
} catch (DataAccessException exception) {
checks.add(new SchemaPreflightCheck(layer, itemName, objectType, "ERROR",
safeMessage(exception), nextAction, true));
}
}
private void addAllObjectCheck(
List<SchemaPreflightCheck> checks,
String layer,
String owner,
String objectName,
String objectType,
String nextAction
) {
try {
List<String> statuses = jdbcTemplate.queryForList("""
SELECT status
FROM all_objects
WHERE owner = ?
AND object_name = ?
AND object_type = ?
""", String.class, owner, objectName, objectType);
if (statuses.isEmpty()) {
checks.add(new SchemaPreflightCheck(layer, owner + "." + objectName, objectType, "MISSING",
"찾을 수 없습니다.", nextAction, true));
return;
}
boolean valid = statuses.stream().allMatch(status -> "VALID".equalsIgnoreCase(status));
checks.add(new SchemaPreflightCheck(layer, owner + "." + objectName, objectType, valid ? "OK" : "WARN",
valid ? "VALID" : "INVALID 또는 컴파일 확인 필요",
valid ? "조치 없음" : nextAction,
true));
} catch (DataAccessException exception) {
checks.add(new SchemaPreflightCheck(layer, owner + "." + objectName, objectType, "ERROR",
safeMessage(exception), nextAction, true));
}
}
private void addPolicyCheck(List<SchemaPreflightCheck> checks) {
try {
int count = count("""
SELECT COUNT(*)
FROM all_policies
WHERE pf_owner = USER
AND function = 'CB_AGENT_DOC_VPD_FILTER'
""");
checks.add(new SchemaPreflightCheck(
"VPD Runtime",
"CB_AGENT_DOC_VPD_FILTER 적용 policy",
"DBMS_RLS POLICY",
count > 0 ? "OK" : "WARN",
count > 0 ? count + "개 policy가 기본 filter를 사용합니다." : "기본 filter가 적용된 TABLE/VIEW가 없습니다.",
count > 0 ? "조치 없음" : "운영 > VPD 설정에서 개별 또는 벌크 적용을 실행하세요.",
false
));
} catch (DataAccessException exception) {
checks.add(new SchemaPreflightCheck("VPD Runtime", "VPD policy catalog", "DBMS_RLS POLICY", "ERROR",
safeMessage(exception), "ALL_POLICIES 조회 권한 또는 VPD 설정을 확인하세요.", true));
}
}
private void addGrantCheck(List<SchemaPreflightCheck> checks, String owner, String objectName, String privilege) {
try {
int count = count("""
SELECT COUNT(*)
FROM all_tab_privs
WHERE table_schema = ?
AND table_name = ?
AND grantee = ?
AND privilege = ?
""", owner, objectName, ORDS_SCHEMA, privilege);
checks.add(new SchemaPreflightCheck(
"Grants",
"GRANT " + privilege + " ON " + owner + "." + objectName + " TO " + ORDS_SCHEMA,
"GRANT",
count > 0 ? "OK" : "MISSING",
count > 0 ? "권한이 부여되어 있습니다." : "권한을 찾을 수 없습니다.",
count > 0 ? "조치 없음" : "sqlcl에서 GRANT " + privilege + " ON " + objectName + " TO " + ORDS_SCHEMA + "를 실행하세요.",
true
));
} catch (DataAccessException exception) {
checks.add(new SchemaPreflightCheck("Grants", objectName + " " + privilege, "GRANT", "ERROR",
safeMessage(exception), "ALL_TAB_PRIVS 조회 권한을 확인하세요.", true));
}
}
private void addProtectedObjectGrantCheck(List<SchemaPreflightCheck> checks) {
if (!exists("SELECT COUNT(*) FROM user_tables WHERE table_name = 'CB_PROTECTED_OBJECT'")) {
checks.add(new SchemaPreflightCheck("Grants", "보호 객체 SELECT grants", "GRANT", "WARN",
"CB_PROTECTED_OBJECT 테이블이 없어 등록 보호 객체 grant를 계산할 수 없습니다.",
"지원 테이블 생성/확인 후 다시 점검하세요.", false));
return;
}
try {
int protectedObjects = count("SELECT COUNT(*) FROM cb_protected_object WHERE enabled_yn = 'Y'");
int grants = count("""
SELECT COUNT(*)
FROM cb_protected_object po
JOIN all_tab_privs tp
ON tp.table_schema = po.owner
AND tp.table_name = po.object_name
AND tp.grantee = ?
AND tp.privilege = 'SELECT'
WHERE po.enabled_yn = 'Y'
""", ORDS_SCHEMA);
String status = protectedObjects == 0 ? "WARN" : (grants >= protectedObjects ? "OK" : "MISSING");
String detail = protectedObjects == 0
? "등록된 활성 보호 객체가 없습니다."
: grants + "/" + protectedObjects + "개 보호 객체에 SELECT grant가 있습니다.";
String nextAction = "ORDS에서 서빙할 TABLE/VIEW마다 GRANT SELECT ON owner.object TO " + ORDS_SCHEMA + "를 실행하세요.";
checks.add(new SchemaPreflightCheck("Grants", "보호 객체 SELECT grants", "GRANT", status, detail,
"OK".equals(status) ? "조치 없음" : nextAction, true));
} catch (DataAccessException exception) {
checks.add(new SchemaPreflightCheck("Grants", "보호 객체 SELECT grants", "GRANT", "ERROR",
safeMessage(exception), "등록 보호 객체와 ALL_TAB_PRIVS 조회 권한을 확인하세요.", true));
}
}
private int count(String sql, Object... args) {
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, args);
return count == null ? 0 : count;
}
private boolean exists(String sql, Object... args) {
try {
return count(sql, args) > 0;
} catch (DataAccessException exception) {
return false;
}
}
private String appDdlPreview() {
StringBuilder builder = new StringBuilder();
builder.append("-- /settings/schema/initialize 버튼이 현재 연결 사용자로 실행하는 SQL preview\n");
builder.append("-- 이 섹션은 백오피스 메타데이터 테이블/시퀀스 보강만 수행합니다.\n\n");
for (SequenceDefinition sequence : SEQUENCES) {
appendSql(builder, sequenceDdl(sequence));
}
for (TableDefinition table : TABLES) {
appendSql(builder, table.ddl());
}
for (ColumnDefinition column : COLUMNS) {
appendSql(builder, column.ddl());
}
appendSql(builder, PROTECTED_COLUMN_MIGRATION_SQL);
appendSql(builder, SETTINGS_MERGE_SQL.replace("?", "'<BACKOFFICE_ORDS_BASE_URL>'"));
return builder.toString();
}
private String sqlclScript(String currentUser) {
String owner = currentUser == null || currentUser.isBlank() ? "ADMIN" : currentUser;
return """
-- sqlcl에서 실행할 VPD/ORDS runtime 준비 순서
-- 1. ADMIN 또는 보호 객체 owner로 접속
@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/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
CONNECT cb_ords/<password>@<tns_alias>
@sql/adb/22_agent_ords_security_ords_handler_setup.sql
-- 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;
""".formatted(owner.toLowerCase());
}
private void appendSql(StringBuilder builder, String sql) {
builder.append(sql.strip()).append(";\n\n");
}
private String sequenceDdl(SequenceDefinition sequence) {
return "CREATE SEQUENCE " + sequence.name().toLowerCase()
+ " START WITH " + sequence.startWith() + " INCREMENT BY 1 NOCACHE";
}
private int oracleErrorCode(RuntimeException exception) {
@@ -247,4 +649,20 @@ public class BackofficeSchemaService {
String normalized = message.replaceAll("\\s+", " ").trim();
return normalized.length() <= 300 ? normalized : normalized.substring(0, 300) + "...";
}
private record SequenceDefinition(String name, int startWith) {
}
private record TableDefinition(String name, String ddl) {
}
private record ColumnDefinition(String table, String column, String ddl) {
private String displayName() {
return table + "." + column;
}
}
private record ConstraintDefinition(String name, String table, String objectType) {
}
}