diff --git a/docs/design/495-schema-preflight-ddl-results/README.md b/docs/design/495-schema-preflight-ddl-results/README.md new file mode 100644 index 0000000..6828b98 --- /dev/null +++ b/docs/design/495-schema-preflight-ddl-results/README.md @@ -0,0 +1,45 @@ +# Redmine #495 - 설정/초기화 preflight와 DDL 실행 결과 상세화 설계 + +## 프로젝트 개요 + +VPD Backoffice는 Oracle Database VPD/ORDS 기능을 백오피스 권한 테이블로 제어하는 Spring Boot 관리 도구다. 사용자는 Oracle DB schema user가 아니라 Bearer Token으로 식별되는 application user이며, 사용자/그룹/역할/권한/행 규칙/컬럼 NULL 처리 설정이 VPD policy function과 ORDS 조회 결과에 반영된다. + +## 목표 + +DB 연결을 다른 ADB나 schema로 바꿨을 때 운영자가 현재 준비 상태를 한 화면에서 판단하고, 앱에서 실행 가능한 초기화 DDL과 sqlcl로 실행해야 하는 작업을 구분해 처리할 수 있게 한다. + +## 현재 문제 + +- `/settings` 화면은 지원 테이블 생성 버튼과 단순 결과 표만 제공한다. +- 현재 연결 사용자와 ORDS parsing schema 권장 구성을 분리해서 보여주지 않는다. +- 필수 테이블, 시퀀스, 컬럼, constraint, VPD context/function/policy, CB_ORDS grants, ORDS enablement 상태를 한 번에 확인하기 어렵다. +- 실행 전에 어떤 SQL이 수행되는지 확인하기 어렵다. +- DDL 실패 시 전역 오류나 짧은 메시지로 끝나 운영자가 어떤 단계에서 실패했는지 파악하기 어렵다. + +## 설계 + +- 설정 화면에 `DB 준비 상태` 섹션을 추가한다. + - 현재 연결 사용자 + - 권장 ORDS parsing schema: `CB_ORDS` + - 앱에서 실행 가능 여부와 sqlcl 필요 여부 +- preflight 항목을 레이어별로 표시한다. + - Backoffice Metadata: 지원 테이블, 시퀀스, 컬럼 + - Constraint/Index: 주요 PK/UK constraint + - VPD Runtime: `CB_AGENT_CTX`, `CB_AGENT_CTX_PKG`, `CB_AGENT_DOC_VPD_FILTER`, `CB_AGENT_CAN_READ_COLUMN`, 적용 policy 존재 여부 + - ORDS Runtime: `CB_ORDS` user, ORDS schema enablement, `CB_ORDS_HANDLER_PKG` + - Grants: `CB_ORDS`에 필요한 EXECUTE/SELECT 권한 +- DDL preview를 추가한다. + - `/settings/schema/initialize` 버튼이 현재 연결 사용자로 실행할 SQL을 보여준다. + - sqlcl로 실행할 스크립트 순서와 대표 grant SQL을 별도로 보여준다. +- DDL 실행 결과를 상세화한다. + - 각 작업의 object, action, status, message, SQL을 표시한다. + - 실패해도 가능한 범위의 결과를 화면에 남겨 운영자가 실패 지점을 확인할 수 있게 한다. + +## 완료 기준 + +- 설정 화면에서 현재 연결 사용자와 권장 ORDS parsing schema가 보인다. +- 필수 테이블, 시퀀스, 컬럼, constraint, package/context/policy/grants/ORDS enablement 상태가 구분되어 보인다. +- 앱 실행 DDL preview와 sqlcl 실행 순서가 복사 가능한 형태로 보인다. +- `지원 테이블 생성/확인` 실행 후 각 DDL의 성공/존재/실패와 SQL을 확인할 수 있다. +- DB 연결이 불안정하거나 지원 테이블이 없어도 가능한 preflight 정보는 표시되고, 오류는 화면 alert로 감싼다. +- `mvn test`와 설정 화면 desktop/mobile 렌더링을 통과한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java index cbc4958..0dd2886 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaActionResult.java @@ -4,6 +4,11 @@ public record SchemaActionResult( String objectName, String action, String status, - String message + String message, + String sqlText ) { + + public SchemaActionResult(String objectName, String action, String status, String message) { + this(objectName, action, status, message, null); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java new file mode 100644 index 0000000..ab23d9f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightCheck.java @@ -0,0 +1,28 @@ +package com.cloudhandson.vpdbackoffice.domain.schema; + +public record SchemaPreflightCheck( + String layer, + String itemName, + String objectType, + String status, + String detail, + String nextAction, + boolean sqlclRequired +) { + + public boolean ok() { + return "OK".equalsIgnoreCase(status); + } + + public boolean warning() { + return "WARN".equalsIgnoreCase(status); + } + + public boolean missing() { + return "MISSING".equalsIgnoreCase(status); + } + + public boolean error() { + return "ERROR".equalsIgnoreCase(status); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java new file mode 100644 index 0000000..20df429 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/schema/SchemaPreflightView.java @@ -0,0 +1,38 @@ +package com.cloudhandson.vpdbackoffice.domain.schema; + +import java.util.List; + +public record SchemaPreflightView( + String currentUser, + String recommendedOrdsSchema, + List checks, + String appDdlPreview, + String sqlclScript +) { + + public long okCount() { + return count("OK"); + } + + public long warnCount() { + return count("WARN"); + } + + public long missingCount() { + return count("MISSING"); + } + + public long errorCount() { + return count("ERROR"); + } + + public boolean hasActionNeeded() { + return warnCount() + missingCount() + errorCount() > 0; + } + + private long count(String status) { + return checks.stream() + .filter(check -> status.equalsIgnoreCase(check.status())) + .count(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index a8a7d54..5b61ad6 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -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 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), + 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 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 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 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<>(); - 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 results, String name, int startWith) { - runIgnoringAlreadyExists(results, name, "SEQUENCE", - "CREATE SEQUENCE " + name + " START WITH " + startWith + " INCREMENT BY 1 NOCACHE"); + private void createSequence(List results, SequenceDefinition sequence) { + runIgnoringAlreadyExists(results, sequence.name(), "SEQUENCE", sequenceDdl(sequence)); } - private void createTable(List results, String name, String ddl) { - runIgnoringAlreadyExists(results, name, "TABLE", ddl); + private void createTable(List results, TableDefinition table) { + runIgnoringAlreadyExists(results, table.name(), "TABLE", table.ddl()); } - private void addColumn(List results, String table, String column, String ddl) { + private void addColumn(List 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 results, String name, String action, String ddl) { + private void runIgnoringAlreadyExists( + List 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 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) { - 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 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, 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/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 EXECUTE ON cb_agent_can_read_column TO cb_ords; + GRANT SELECT ON . 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) { + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java index 523641b..789086b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java @@ -31,6 +31,13 @@ public class SettingController { model.addAttribute("runtimeErrorTitle", error.title()); model.addAttribute("runtimeErrorMessage", error.message()); } + try { + model.addAttribute("schemaPreflight", backofficeSchemaService.preflight()); + } catch (DataAccessException exception) { + RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception); + model.addAttribute("schemaPreflightErrorTitle", error.title()); + model.addAttribute("schemaPreflightErrorMessage", error.message()); + } return "settings"; } @@ -46,8 +53,16 @@ public class SettingController { @PostMapping("/settings/schema/initialize") public String initializeSchema(RedirectAttributes redirectAttributes) { - redirectAttributes.addFlashAttribute("schemaResults", backofficeSchemaService.initializeSchema()); - redirectAttributes.addFlashAttribute("message", "백오피스 지원 테이블 DDL을 실행했습니다."); + var results = backofficeSchemaService.initializeSchema(); + long failures = results.stream() + .filter(result -> "FAILED".equalsIgnoreCase(result.status())) + .count(); + redirectAttributes.addFlashAttribute("schemaResults", results); + if (failures > 0) { + redirectAttributes.addFlashAttribute("error", "백오피스 지원 DDL 실행 중 " + failures + "개 작업이 실패했습니다. 결과와 SQL을 확인하세요."); + } else { + redirectAttributes.addFlashAttribute("message", "백오피스 지원 테이블 DDL을 실행했습니다."); + } return "redirect:/settings"; } } diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 87c8b32..61c2311 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -1098,6 +1098,85 @@ body { white-space: pre-wrap; } +.schema-summary { + display: grid; + gap: .75rem; + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.schema-summary div { + background: var(--rw-surface-muted); + border: 1px solid var(--rw-border); + border-radius: 8px; + min-width: 0; + padding: .7rem .8rem; +} + +.schema-summary span { + color: var(--rw-muted); + display: block; + font-size: .76rem; + line-height: 1.35; +} + +.schema-summary strong { + color: var(--rw-text); + display: block; + font-size: .98rem; + font-weight: 800; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.schema-preview-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.schema-preview-grid details { + border: 1px solid var(--rw-border); + border-radius: 8px; + min-width: 0; +} + +.schema-preview-grid summary { + cursor: pointer; + font-weight: 800; + padding: .75rem .9rem; +} + +.schema-sql-preview { + background: #211f1d; + border-top: 1px solid var(--rw-border); + color: #fbf9f7; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: .76rem; + line-height: 1.45; + margin: 0; + max-height: 28rem; + overflow: auto; + padding: .85rem; + white-space: pre-wrap; + word-break: break-word; +} + +.table-responsive > .schema-preflight-table th, +.table-responsive > .schema-preflight-table td, +.table-responsive > .schema-preflight-table code { + overflow-wrap: anywhere; + white-space: normal; +} + +.schema-preflight-table th:nth-child(1), +.schema-preflight-table td:nth-child(1), +.schema-preflight-table th:nth-child(3), +.schema-preflight-table td:nth-child(3), +.schema-preflight-table th:nth-child(4), +.schema-preflight-table td:nth-child(4) { + width: 8rem; +} + .policy-function-button { border-radius: 8px; max-width: 100%; @@ -1329,6 +1408,14 @@ body { } @media (max-width: 920px) { + .schema-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .schema-preview-grid { + grid-template-columns: 1fr; + } + .architecture-strip { grid-template-columns: 1fr; } @@ -1339,6 +1426,10 @@ body { } @media (max-width: 640px) { + .schema-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + main.container { max-width: 100%; min-width: 0; diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index f61b7dd..4c8879d 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -10,10 +10,15 @@
+
DB 준비 필요
지원 테이블을 생성하세요.
+
+ DB 준비 상태 확인 불가 +
DB 연결을 확인하세요.
+

ORDS

@@ -27,10 +32,97 @@
+
+
+
+

DB 준비 상태

+

현재 연결 사용자 기준으로 백오피스 메타데이터, VPD runtime, ORDS runtime, grant 상태를 점검합니다.

+
+
+
+
+ 현재 연결 사용자 + ADMIN +
+
+ 권장 ORDS parsing schema + CB_ORDS +
+
+ OK + 0 +
+
+ WARN + 0 +
+
+ MISSING + 0 +
+
+ ERROR + 0 +
+
+
+ 앱에서 실행 가능한 항목은 아래 `지원 테이블 생성/확인` 버튼으로 보강하고, `sqlcl 필요` 항목은 preview 순서대로 DB 권한을 가진 세션에서 실행하세요. +
+
+ + + + + + + + + + + + + + + + + + + + + +
LayerItemTypeStatusDetailNext Action
Backoffice MetadataCB_APP_USERTABLE + OK + 존재합니다. + 앱 실행 가능 +
조치 없음
+
+
+
+ +
+

SQL Preview

+

+ 앱 버튼이 실행할 SQL과 sqlcl로 실행할 runtime 준비 순서를 구분해서 확인합니다. +

+
+
+ 앱에서 실행하는 지원 DDL +
CREATE TABLE ...
+
+
+ sqlcl로 실행할 VPD/ORDS runtime 순서 +
@sql/adb/...
+
+
+
+

DB 스키마 초기화

- 현재 연결된 DB에 백오피스가 사용하는 지원 테이블과 시퀀스를 생성하거나 보강합니다. + 현재 연결된 DB 사용자에 백오피스 지원 테이블, 시퀀스, 컬럼을 생성하거나 보강합니다. VPD context, ORDS enablement, grant는 sqlcl preview 순서를 사용하세요.

@@ -44,6 +136,7 @@ Action Status Message + SQL @@ -52,10 +145,17 @@ TABLE CREATED 생성했습니다. + +
+ 보기 +
CREATE TABLE ...
+
+ - +