fix #495: add schema preflight and DDL details
This commit is contained in:
45
docs/design/495-schema-preflight-ddl-results/README.md
Normal file
45
docs/design/495-schema-preflight-ddl-results/README.md
Normal file
@@ -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 렌더링을 통과한다.
|
||||||
@@ -4,6 +4,11 @@ public record SchemaActionResult(
|
|||||||
String objectName,
|
String objectName,
|
||||||
String action,
|
String action,
|
||||||
String status,
|
String status,
|
||||||
String message
|
String message,
|
||||||
|
String sqlText
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
public SchemaActionResult(String objectName, String action, String status, String message) {
|
||||||
|
this(objectName, action, status, message, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record SchemaPreflightView(
|
||||||
|
String currentUser,
|
||||||
|
String recommendedOrdsSchema,
|
||||||
|
List<SchemaPreflightCheck> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schema.SchemaActionResult;
|
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.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -12,6 +15,187 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class BackofficeSchemaService {
|
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 JdbcTemplate jdbcTemplate;
|
||||||
private final BackofficeProperties properties;
|
private final BackofficeProperties properties;
|
||||||
|
|
||||||
@@ -20,212 +204,430 @@ public class BackofficeSchemaService {
|
|||||||
this.properties = properties;
|
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
|
@Transactional
|
||||||
public List<SchemaActionResult> initializeSchema() {
|
public List<SchemaActionResult> initializeSchema() {
|
||||||
List<SchemaActionResult> results = new ArrayList<>();
|
List<SchemaActionResult> results = new ArrayList<>();
|
||||||
createSequence(results, "cb_agent_bearer_key_seq", 1000);
|
try {
|
||||||
createSequence(results, "cb_permission_seq", 1000);
|
currentUser();
|
||||||
createSequence(results, "cb_permission_rule_seq", 10000);
|
} catch (DataAccessException exception) {
|
||||||
createSequence(results, "cb_ords_probe_audit_seq", 1);
|
results.add(new SchemaActionResult("DB_CONNECTION", "PREFLIGHT", "FAILED",
|
||||||
|
"DB 연결을 확인할 수 없습니다. " + safeMessage(exception), "SELECT USER FROM dual"));
|
||||||
createTable(results, "cb_app_user", """
|
return results;
|
||||||
CREATE TABLE cb_app_user (
|
}
|
||||||
user_id NUMBER PRIMARY KEY,
|
for (SequenceDefinition sequence : SEQUENCES) {
|
||||||
user_name VARCHAR2(100) NOT NULL UNIQUE,
|
createSequence(results, sequence);
|
||||||
employee_no VARCHAR2(50),
|
}
|
||||||
dept_code VARCHAR2(50),
|
for (TableDefinition table : TABLES) {
|
||||||
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
|
createTable(results, table);
|
||||||
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
|
}
|
||||||
)
|
for (ColumnDefinition column : COLUMNS) {
|
||||||
""");
|
addColumn(results, column);
|
||||||
createTable(results, "cb_app_role", """
|
}
|
||||||
CREATE TABLE cb_app_role (
|
runDml(results, "CB_PROTECTED_COLUMN", "DATA", PROTECTED_COLUMN_MIGRATION_SQL,
|
||||||
role_id NUMBER PRIMARY KEY,
|
"민감 컬럼 legacy 값을 보강했습니다.", "UPDATED");
|
||||||
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
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
seedDefaultSettings(results);
|
seedDefaultSettings(results);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createSequence(List<SchemaActionResult> results, String name, int startWith) {
|
private void createSequence(List<SchemaActionResult> results, SequenceDefinition sequence) {
|
||||||
runIgnoringAlreadyExists(results, name, "SEQUENCE",
|
runIgnoringAlreadyExists(results, sequence.name(), "SEQUENCE", sequenceDdl(sequence));
|
||||||
"CREATE SEQUENCE " + name + " START WITH " + startWith + " INCREMENT BY 1 NOCACHE");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createTable(List<SchemaActionResult> results, String name, String ddl) {
|
private void createTable(List<SchemaActionResult> results, TableDefinition table) {
|
||||||
runIgnoringAlreadyExists(results, name, "TABLE", ddl);
|
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 {
|
try {
|
||||||
jdbcTemplate.execute(ddl);
|
jdbcTemplate.execute(column.ddl());
|
||||||
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "CREATED", "컬럼을 추가했습니다."));
|
results.add(new SchemaActionResult(column.displayName(), "COLUMN", "CREATED", "컬럼을 추가했습니다.", column.ddl()));
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
if (oracleErrorCode(exception) == 1430) {
|
if (oracleErrorCode(exception) == 1430) {
|
||||||
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "EXISTS", "이미 존재합니다."));
|
results.add(new SchemaActionResult(column.displayName(), "COLUMN", "EXISTS", "이미 존재합니다.", column.ddl()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
results.add(new SchemaActionResult(table + "." + column, "COLUMN", "FAILED", safeMessage(exception)));
|
results.add(new SchemaActionResult(column.displayName(), "COLUMN", "FAILED", safeMessage(exception), column.ddl()));
|
||||||
throw exception;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
jdbcTemplate.execute(ddl);
|
jdbcTemplate.execute(ddl);
|
||||||
results.add(new SchemaActionResult(name, action, "CREATED", "생성했습니다."));
|
results.add(new SchemaActionResult(name, action, "CREATED", "생성했습니다.", ddl));
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
if (oracleErrorCode(exception) == 955) {
|
if (oracleErrorCode(exception) == 955) {
|
||||||
results.add(new SchemaActionResult(name, action, "EXISTS", "이미 존재합니다."));
|
results.add(new SchemaActionResult(name, action, "EXISTS", "이미 존재합니다.", ddl));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception)));
|
results.add(new SchemaActionResult(name, action, "FAILED", safeMessage(exception), ddl));
|
||||||
throw exception;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
private void seedDefaultSettings(List<SchemaActionResult> results) {
|
||||||
jdbcTemplate.update("""
|
try {
|
||||||
MERGE INTO cb_backoffice_setting dst
|
jdbcTemplate.update(SETTINGS_MERGE_SQL, properties.ords().baseUrl());
|
||||||
USING (
|
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED",
|
||||||
SELECT 'ORDS_BASE_URL' setting_key, ? setting_value
|
"기본 ORDS URL 설정을 보강했습니다.", SETTINGS_MERGE_SQL));
|
||||||
FROM dual
|
} catch (RuntimeException exception) {
|
||||||
) src
|
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "FAILED",
|
||||||
ON (dst.setting_key = src.setting_key)
|
safeMessage(exception), SETTINGS_MERGE_SQL));
|
||||||
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 String currentUser() {
|
||||||
""", properties.ords().baseUrl());
|
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
|
||||||
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED", "기본 ORDS URL 설정을 보강했습니다."));
|
}
|
||||||
|
|
||||||
|
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) {
|
private int oracleErrorCode(RuntimeException exception) {
|
||||||
@@ -247,4 +649,20 @@ public class BackofficeSchemaService {
|
|||||||
String normalized = message.replaceAll("\\s+", " ").trim();
|
String normalized = message.replaceAll("\\s+", " ").trim();
|
||||||
return normalized.length() <= 300 ? normalized : normalized.substring(0, 300) + "...";
|
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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ public class SettingController {
|
|||||||
model.addAttribute("runtimeErrorTitle", error.title());
|
model.addAttribute("runtimeErrorTitle", error.title());
|
||||||
model.addAttribute("runtimeErrorMessage", error.message());
|
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";
|
return "settings";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +53,16 @@ public class SettingController {
|
|||||||
|
|
||||||
@PostMapping("/settings/schema/initialize")
|
@PostMapping("/settings/schema/initialize")
|
||||||
public String initializeSchema(RedirectAttributes redirectAttributes) {
|
public String initializeSchema(RedirectAttributes redirectAttributes) {
|
||||||
redirectAttributes.addFlashAttribute("schemaResults", backofficeSchemaService.initializeSchema());
|
var results = backofficeSchemaService.initializeSchema();
|
||||||
redirectAttributes.addFlashAttribute("message", "백오피스 지원 테이블 DDL을 실행했습니다.");
|
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";
|
return "redirect:/settings";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1098,6 +1098,85 @@ body {
|
|||||||
white-space: pre-wrap;
|
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 {
|
.policy-function-button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -1329,6 +1408,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
|
.schema-summary {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-preview-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.architecture-strip {
|
.architecture-strip {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1339,6 +1426,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.schema-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
main.container {
|
main.container {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -10,10 +10,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||||
|
<div class="alert alert-danger" th:if="${error}" th:text="${error}"></div>
|
||||||
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
|
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
|
||||||
<strong th:text="${runtimeErrorTitle}">DB 준비 필요</strong>
|
<strong th:text="${runtimeErrorTitle}">DB 준비 필요</strong>
|
||||||
<div th:text="${runtimeErrorMessage}">지원 테이블을 생성하세요.</div>
|
<div th:text="${runtimeErrorMessage}">지원 테이블을 생성하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="alert alert-warning" th:if="${schemaPreflightErrorMessage}">
|
||||||
|
<strong th:text="${schemaPreflightErrorTitle}">DB 준비 상태 확인 불가</strong>
|
||||||
|
<div th:text="${schemaPreflightErrorMessage}">DB 연결을 확인하세요.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<h2>ORDS</h2>
|
<h2>ORDS</h2>
|
||||||
@@ -27,10 +32,97 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band mt-3" th:if="${schemaPreflight}">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>DB 준비 상태</h2>
|
||||||
|
<p>현재 연결 사용자 기준으로 백오피스 메타데이터, VPD runtime, ORDS runtime, grant 상태를 점검합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schema-summary">
|
||||||
|
<div>
|
||||||
|
<span>현재 연결 사용자</span>
|
||||||
|
<strong th:text="${schemaPreflight.currentUser()}">ADMIN</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>권장 ORDS parsing schema</span>
|
||||||
|
<strong th:text="${schemaPreflight.recommendedOrdsSchema()}">CB_ORDS</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>OK</span>
|
||||||
|
<strong th:text="${schemaPreflight.okCount()}">0</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>WARN</span>
|
||||||
|
<strong th:text="${schemaPreflight.warnCount()}">0</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>MISSING</span>
|
||||||
|
<strong th:text="${schemaPreflight.missingCount()}">0</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>ERROR</span>
|
||||||
|
<strong th:text="${schemaPreflight.errorCount()}">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info mt-3 mb-3" th:if="${schemaPreflight.hasActionNeeded()}">
|
||||||
|
앱에서 실행 가능한 항목은 아래 `지원 테이블 생성/확인` 버튼으로 보강하고, `sqlcl 필요` 항목은 preview 순서대로 DB 권한을 가진 세션에서 실행하세요.
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle schema-preflight-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Layer</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
<th>Next Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="check : ${schemaPreflight.checks()}">
|
||||||
|
<td th:text="${check.layer()}">Backoffice Metadata</td>
|
||||||
|
<td><code th:text="${check.itemName()}">CB_APP_USER</code></td>
|
||||||
|
<td th:text="${check.objectType()}">TABLE</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge"
|
||||||
|
th:classappend="${check.ok()} ? ' text-bg-success' : (${check.warning()} ? ' text-bg-warning' : (${check.missing()} ? ' text-bg-secondary' : ' text-bg-danger'))"
|
||||||
|
th:text="${check.status()}">OK</span>
|
||||||
|
</td>
|
||||||
|
<td th:text="${check.detail()}">존재합니다.</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" th:classappend="${check.sqlclRequired()} ? ' text-bg-warning' : ' text-bg-info'"
|
||||||
|
th:text="${check.sqlclRequired()} ? 'sqlcl 필요' : '앱 실행 가능'">앱 실행 가능</span>
|
||||||
|
<div class="mt-1" th:text="${check.nextAction()}">조치 없음</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band mt-3" th:if="${schemaPreflight}">
|
||||||
|
<h2>SQL Preview</h2>
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
앱 버튼이 실행할 SQL과 sqlcl로 실행할 runtime 준비 순서를 구분해서 확인합니다.
|
||||||
|
</p>
|
||||||
|
<div class="schema-preview-grid">
|
||||||
|
<details open>
|
||||||
|
<summary>앱에서 실행하는 지원 DDL</summary>
|
||||||
|
<pre class="schema-sql-preview" th:text="${schemaPreflight.appDdlPreview()}">CREATE TABLE ...</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>sqlcl로 실행할 VPD/ORDS runtime 순서</summary>
|
||||||
|
<pre class="schema-sql-preview" th:text="${schemaPreflight.sqlclScript()}">@sql/adb/...</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="content-band mt-3">
|
<section class="content-band mt-3">
|
||||||
<h2>DB 스키마 초기화</h2>
|
<h2>DB 스키마 초기화</h2>
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
현재 연결된 DB에 백오피스가 사용하는 지원 테이블과 시퀀스를 생성하거나 보강합니다.
|
현재 연결된 DB 사용자에 백오피스 지원 테이블, 시퀀스, 컬럼을 생성하거나 보강합니다. VPD context, ORDS enablement, grant는 sqlcl preview 순서를 사용하세요.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="/settings/schema/initialize" class="inline-form">
|
<form method="post" action="/settings/schema/initialize" class="inline-form">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
@@ -44,6 +136,7 @@
|
|||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
|
<th>SQL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -52,10 +145,17 @@
|
|||||||
<td th:text="${result.action()}">TABLE</td>
|
<td th:text="${result.action()}">TABLE</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge"
|
<span class="badge"
|
||||||
th:classappend="${result.status() == 'CREATED' || result.status() == 'MERGED'} ? ' text-bg-success' : (${result.status() == 'EXISTS'} ? ' text-bg-secondary' : ' text-bg-danger')"
|
th:classappend="${result.status() == 'CREATED' || result.status() == 'MERGED' || result.status() == 'UPDATED'} ? ' text-bg-success' : (${result.status() == 'EXISTS'} ? ' text-bg-secondary' : ' text-bg-danger')"
|
||||||
th:text="${result.status()}">CREATED</span>
|
th:text="${result.status()}">CREATED</span>
|
||||||
</td>
|
</td>
|
||||||
<td th:text="${result.message()}">생성했습니다.</td>
|
<td th:text="${result.message()}">생성했습니다.</td>
|
||||||
|
<td>
|
||||||
|
<details th:if="${result.sqlText()}">
|
||||||
|
<summary>보기</summary>
|
||||||
|
<pre class="table-pre" th:text="${result.sqlText()}">CREATE TABLE ...</pre>
|
||||||
|
</details>
|
||||||
|
<span class="text-muted" th:unless="${result.sqlText()}">-</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user