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; @Service public class BackofficeSchemaService { private static final String ORDS_SCHEMA = "CB_ORDS"; private static final List 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 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), stakeholder_user_id VARCHAR2(30), 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, stakeholder_user_id VARCHAR2(30), 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, description VARCHAR2(200) ) """), 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_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, 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 ) """), new TableDefinition("CB_VPD_POLICY_NOTE", """ CREATE TABLE cb_vpd_policy_note ( object_owner VARCHAR2(128) NOT NULL, object_name VARCHAR2(128) NOT NULL, policy_name VARCHAR2(128) NOT NULL, description VARCHAR2(500) NOT NULL, updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, CONSTRAINT cb_vpd_policy_note_pk PRIMARY KEY (object_owner, object_name, policy_name) ) """), new TableDefinition("CB_VPD_FILTER_NOTE", """ CREATE TABLE cb_vpd_filter_note ( function_owner VARCHAR2(128) NOT NULL, function_name VARCHAR2(128) NOT NULL, description VARCHAR2(500) NOT NULL, updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, CONSTRAINT cb_vpd_filter_note_pk PRIMARY KEY (function_owner, function_name) ) """) ); private static final List 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_APP_USER", "STAKEHOLDER_USER_ID", "ALTER TABLE cb_app_user ADD (stakeholder_user_id VARCHAR2(30))"), new ColumnDefinition("CB_AGENT_BEARER_KEY", "STAKEHOLDER_USER_ID", "ALTER TABLE cb_agent_bearer_key ADD (stakeholder_user_id VARCHAR2(30))"), new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION", "ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"), new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION", "ALTER TABLE cb_protected_object 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 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 static final List 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_IDENTIFIER_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; public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) { this.jdbcTemplate = jdbcTemplate; this.properties = properties; } public SchemaPreflightView preflight() { String currentUser = currentUser(); List 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 initializeSchema() { List results = new ArrayList<>(); 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"); seedDefaultMaskingRules(results); seedDefaultSettings(results); return results; } private void createSequence(List results, SequenceDefinition sequence) { runIgnoringAlreadyExists(results, sequence.name(), "SEQUENCE", sequenceDdl(sequence)); } private void createTable(List results, TableDefinition table) { runIgnoringAlreadyExists(results, table.name(), "TABLE", table.ddl()); } private void addColumn(List results, ColumnDefinition column) { try { 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(column.displayName(), "COLUMN", "EXISTS", "이미 존재합니다.", column.ddl())); return; } results.add(new SchemaActionResult(column.displayName(), "COLUMN", "FAILED", safeMessage(exception), column.ddl())); } } private void runIgnoringAlreadyExists( List results, String name, String action, String ddl ) { try { jdbcTemplate.execute(ddl); results.add(new SchemaActionResult(name, action, "CREATED", "생성했습니다.", ddl)); } catch (RuntimeException exception) { if (oracleErrorCode(exception) == 955) { results.add(new SchemaActionResult(name, action, "EXISTS", "이미 존재합니다.", ddl)); return; } results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception), ddl)); } } private void runDml( List 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 results) { 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 void seedDefaultMaskingRules(List 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); } private void addCountCheck( List 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 checks, String layer, String itemName, String objectType, String nextAction ) { addUserObjectCheck(checks, layer, itemName, objectType, nextAction, itemName); } private void addUserObjectCheck( List checks, String layer, String itemName, String objectType, String nextAction, String objectName ) { try { List 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 checks, String layer, String owner, String objectName, String objectType, String nextAction ) { try { List 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 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 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 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, MASKING_RULE_SEED_SQL.replace("?", "''")); appendSql(builder, SETTINGS_MERGE_SQL.replace("?", "''")); 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/71_sg_identity_administration.sql @sql/adb/21_agent_ords_security_ords_enable_schema.sql -- 2. ORDS parsing schema로 접속 CONNECT cb_ords/@ @sql/adb/22_agent_ords_security_ords_handler_setup.sql -- 3. 대표 권한 부여 SQL CONNECT %s/@ GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords; GRANT SELECT ON . TO cb_ords; -- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결 -- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다. """.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) { Throwable current = exception; while (current != null) { if (current instanceof SQLException sqlException) { return Math.abs(sqlException.getErrorCode()); } current = current.getCause(); } return 0; } private String safeMessage(Throwable throwable) { String message = throwable.getMessage(); if (message == null || message.isBlank()) { return throwable.getClass().getSimpleName(); } 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) { } private record MaskingRuleSeed(String code, String name, String templateCode, String description) { } }