feat: add dedicated DDS permission backoffice instance
This commit is contained in:
45
dds-backoffice/README.md
Normal file
45
dds-backoffice/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# DDS Permission Console
|
||||||
|
|
||||||
|
`dds-backoffice`는 기존 VPD 백오피스와 코드·프로세스·포트를 분리한 Deep Data Security 전용 인스턴스입니다.
|
||||||
|
|
||||||
|
- 기본 포트: `8083`
|
||||||
|
- 기본 화면: `/` 또는 `/dds`
|
||||||
|
- 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
|
||||||
|
- 조회 방식: 선택한 DDS `END USER` 자격으로 직접 Oracle JDBC 연결
|
||||||
|
|
||||||
|
## VPD 인스턴스와의 차이
|
||||||
|
|
||||||
|
VPD 인스턴스는 공통 DB 계정으로 접속한 뒤 Bearer 사용자와 권한 테이블을 세션 컨텍스트에 저장하고 VPD 함수가 조건을 계산합니다. 이 앱은 그 경로를 재사용하지 않습니다.
|
||||||
|
|
||||||
|
이 앱의 조회 경계는 다음과 같습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
END USER → DATA ROLE → DATA GRANT → V_DDS_CUSTOMERS_* → 조회 결과
|
||||||
|
```
|
||||||
|
|
||||||
|
ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것만으로는 DDS 보안 컨텍스트가 전달되지 않습니다. 따라서 이 비교 인스턴스는 DDS `END USER` 직접 logon을 검증합니다. Bearer/ORDS 전달이 필요하면 지원 드라이버의 `EndUserSecurityContext`를 별도 설계해야 합니다.
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
먼저 기존 DDS SQL을 적용합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh dds-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
그 다음 DDS 인스턴스를 실행합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dds-backoffice
|
||||||
|
export DDS_BACKOFFICE_DB_URL="${BACKOFFICE_DB_URL}"
|
||||||
|
export DDSUSER_MY_PASSWORD='...'
|
||||||
|
export DDSUSER_PG_PASSWORD='...'
|
||||||
|
export DDSUSER_BOTH_PASSWORD='...'
|
||||||
|
export DDSUSER_NONE_PASSWORD='...'
|
||||||
|
mvn -DskipTests package
|
||||||
|
java -jar target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
관리자 로그인은 `DDS_BACKOFFICE_ADMIN_USER` / `DDS_BACKOFFICE_ADMIN_PASSWORD`를 사용합니다. DDS END USER 비밀번호는 서버 환경 변수로만 읽고 화면에 표시하거나 저장하지 않습니다.
|
||||||
|
|
||||||
|
VM 배포는 `scripts/deploy-dds-backoffice-vm.sh`를 사용합니다. 스크립트는 기존 `/home/opc/apps/vpd-backoffice`의 wallet과 환경값을 읽어 `/home/opc/apps/dds-backoffice`를 별도 프로세스로 기동합니다. 기본 바인딩은 `127.0.0.1:8083`이며, 인터넷 공개는 HTTPS reverse proxy와 NSG/firewall 승인을 별도로 거쳐야 합니다.
|
||||||
64
dds-backoffice/pom.xml
Normal file
64
dds-backoffice/pom.xml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.8</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.cloudhandson</groupId>
|
||||||
|
<artifactId>dds-permission-backoffice</artifactId>
|
||||||
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
|
<name>dds-permission-backoffice</name>
|
||||||
|
<description>Dedicated Deep Data Security permission comparison console</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.oracle.database.jdbc</groupId>
|
||||||
|
<artifactId>ojdbc11</artifactId>
|
||||||
|
<version>${oracle.jdbc.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableConfigurationProperties(DdsProperties.class)
|
||||||
|
public class DdsBackofficeApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(DdsBackofficeApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(BackofficeProperties.class)
|
||||||
|
public class AppConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
DdsDatabaseDriver ddsDatabaseDriver() {
|
||||||
|
return new DdsDatabaseDriver();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class DdsDatabaseDriver {
|
||||||
|
|
||||||
|
DdsDatabaseDriver() {
|
||||||
|
try {
|
||||||
|
Class.forName("oracle.jdbc.OracleDriver");
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
throw new IllegalStateException("Oracle JDBC 드라이버를 찾을 수 없습니다.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "backoffice")
|
||||||
|
public record BackofficeProperties(Security security) {
|
||||||
|
|
||||||
|
public record Security(String adminUser, String adminPassword, boolean requireHttps) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "dds")
|
||||||
|
public record DdsProperties(
|
||||||
|
String dbUrl,
|
||||||
|
Duration queryTimeout,
|
||||||
|
String pgObject,
|
||||||
|
String myObject,
|
||||||
|
Map<String, User> users
|
||||||
|
) {
|
||||||
|
|
||||||
|
private static final Pattern OBJECT_NAME = Pattern.compile(
|
||||||
|
"[A-Za-z][A-Za-z0-9_$#]*(\\.[A-Za-z][A-Za-z0-9_$#]*)*"
|
||||||
|
);
|
||||||
|
|
||||||
|
public DdsProperties {
|
||||||
|
dbUrl = dbUrl == null ? "" : dbUrl.trim();
|
||||||
|
queryTimeout = queryTimeout == null ? Duration.ofSeconds(10) : queryTimeout;
|
||||||
|
pgObject = normalizeObject(pgObject, "ADMIN.V_DDS_CUSTOMERS_PG");
|
||||||
|
myObject = normalizeObject(myObject, "ADMIN.V_DDS_CUSTOMERS_MY");
|
||||||
|
var normalizedUsers = new LinkedHashMap<String, User>();
|
||||||
|
if (users != null) {
|
||||||
|
users.forEach((key, value) -> {
|
||||||
|
if (key != null && value != null) {
|
||||||
|
normalizedUsers.put(key.trim().toLowerCase(), value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
users = Map.copyOf(normalizedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String objectFor(String sourceKey) {
|
||||||
|
if ("PG".equalsIgnoreCase(sourceKey)) {
|
||||||
|
return pgObject;
|
||||||
|
}
|
||||||
|
if ("MY".equalsIgnoreCase(sourceKey)) {
|
||||||
|
return myObject;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeObject(String value, String fallback) {
|
||||||
|
var candidate = value == null || value.isBlank() ? fallback : value.trim();
|
||||||
|
if (!OBJECT_NAME.matcher(candidate).matches()) {
|
||||||
|
throw new IllegalArgumentException("DDS 보호 객체 이름은 스키마.객체 형식이어야 합니다.");
|
||||||
|
}
|
||||||
|
return candidate.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record User(String label, String description, String username, String password) {
|
||||||
|
|
||||||
|
public boolean configured() {
|
||||||
|
return username != null && !username.isBlank()
|
||||||
|
&& password != null && !password.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityFilterChain securityFilterChain(
|
||||||
|
HttpSecurity http,
|
||||||
|
BackofficeProperties properties
|
||||||
|
) throws Exception {
|
||||||
|
if (properties.security().requireHttps()) {
|
||||||
|
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
|
||||||
|
}
|
||||||
|
|
||||||
|
return http
|
||||||
|
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||||
|
.includeSubDomains(true)
|
||||||
|
.maxAgeInSeconds(31_536_000)))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/css/**", "/js/**", "/health", "/login").permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
|
.httpBasic(basic -> {
|
||||||
|
})
|
||||||
|
.formLogin(login -> login
|
||||||
|
.loginPage("/login")
|
||||||
|
.permitAll())
|
||||||
|
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
UserDetailsService userDetailsService(
|
||||||
|
BackofficeProperties properties,
|
||||||
|
PasswordEncoder passwordEncoder
|
||||||
|
) {
|
||||||
|
var security = properties.security();
|
||||||
|
var user = User.withUsername(security.adminUser())
|
||||||
|
.password(passwordEncoder.encode(security.adminPassword()))
|
||||||
|
.roles("ADMIN")
|
||||||
|
.build();
|
||||||
|
return new InMemoryUserDetailsManager(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PasswordEncoder passwordEncoder() {
|
||||||
|
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record DdsCustomerRow(
|
||||||
|
long customerId,
|
||||||
|
String fullName,
|
||||||
|
String email,
|
||||||
|
LocalDateTime signupDate,
|
||||||
|
String region
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DdsQueryResult(
|
||||||
|
String userKey,
|
||||||
|
String sourceKey,
|
||||||
|
String userLabel,
|
||||||
|
String sourceLabel,
|
||||||
|
String objectName,
|
||||||
|
boolean success,
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
String sessionUser,
|
||||||
|
String endUser,
|
||||||
|
Integer oracleCode,
|
||||||
|
List<DdsCustomerRow> rows
|
||||||
|
) {
|
||||||
|
|
||||||
|
public DdsQueryResult {
|
||||||
|
rows = rows == null ? List.of() : List.copyOf(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasRows() {
|
||||||
|
return !rows.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
public record DdsSourceOption(
|
||||||
|
String key,
|
||||||
|
String label,
|
||||||
|
String objectName,
|
||||||
|
String description
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
public record DdsUserOption(
|
||||||
|
String key,
|
||||||
|
String label,
|
||||||
|
String username,
|
||||||
|
String description,
|
||||||
|
boolean configured
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsCustomerRow;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsSourceOption;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsUserOption;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DdsQueryService {
|
||||||
|
|
||||||
|
private static final Pattern SAFE_SOURCE = Pattern.compile("PG|MY");
|
||||||
|
private final DdsProperties properties;
|
||||||
|
|
||||||
|
public DdsQueryService(DdsProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DdsUserOption> users() {
|
||||||
|
return properties.users().entrySet().stream()
|
||||||
|
.map(entry -> {
|
||||||
|
var user = entry.getValue();
|
||||||
|
return new DdsUserOption(
|
||||||
|
entry.getKey(),
|
||||||
|
valueOrDefault(user.label(), entry.getKey()),
|
||||||
|
valueOrDefault(user.username(), "미설정"),
|
||||||
|
valueOrDefault(user.description(), "DDS 보안 사용자"),
|
||||||
|
user.configured()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DdsSourceOption> sources() {
|
||||||
|
return List.of(
|
||||||
|
new DdsSourceOption(
|
||||||
|
"PG", "PostgreSQL 원본", properties.pgObject(),
|
||||||
|
"DDS DATA GRANT가 허용한 PostgreSQL 데이터만 반환합니다."
|
||||||
|
),
|
||||||
|
new DdsSourceOption(
|
||||||
|
"MY", "MySQL 원본", properties.myObject(),
|
||||||
|
"DDS DATA GRANT가 허용한 MySQL 데이터만 반환합니다."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsQueryResult query(String userKey, String sourceKey, String searchText, int requestedLimit) {
|
||||||
|
var normalizedUserKey = normalizeUserKey(userKey);
|
||||||
|
var normalizedSourceKey = normalizeSourceKey(sourceKey);
|
||||||
|
var user = properties.users().get(normalizedUserKey);
|
||||||
|
var source = sources().stream()
|
||||||
|
.filter(option -> option.key().equals(normalizedSourceKey))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다."));
|
||||||
|
var userLabel = user == null ? normalizedUserKey : valueOrDefault(user.label(), normalizedUserKey);
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
|
||||||
|
"사용자를 찾을 수 없습니다.", "선택한 DDS END USER가 설정에 없습니다.", null);
|
||||||
|
}
|
||||||
|
if (properties.dbUrl().isBlank()) {
|
||||||
|
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
|
||||||
|
"DB 접속 정보가 없습니다.", "DDS_BACKOFFICE_DB_URL 또는 BACKOFFICE_DB_URL을 설정하세요.", null);
|
||||||
|
}
|
||||||
|
if (!user.configured()) {
|
||||||
|
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
|
||||||
|
"DDS 사용자 자격증명이 설정되지 않았습니다.",
|
||||||
|
"이 인스턴스의 환경 변수에 해당 END USER 비밀번호를 설정하세요.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var limit = Math.max(1, Math.min(requestedLimit, 100));
|
||||||
|
var search = searchText == null ? "" : searchText.trim();
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
properties.dbUrl(), user.username(), user.password())) {
|
||||||
|
connection.setReadOnly(true);
|
||||||
|
var timeoutSeconds = timeoutSeconds(properties.queryTimeout());
|
||||||
|
var sessionUser = readSingleValue(connection,
|
||||||
|
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds);
|
||||||
|
var endUser = readSingleValue(connection,
|
||||||
|
"SELECT ORA_END_USER_CONTEXT.username FROM dual", timeoutSeconds);
|
||||||
|
|
||||||
|
var sql = "SELECT customer_id, full_name, email, signup_date, region FROM "
|
||||||
|
+ source.objectName()
|
||||||
|
+ (search.isBlank() ? "" : " WHERE UPPER(full_name) LIKE ?")
|
||||||
|
+ " ORDER BY customer_id FETCH FIRST " + limit + " ROWS ONLY";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
|
if (!search.isBlank()) {
|
||||||
|
statement.setString(1, "%" + search.toUpperCase(Locale.ROOT) + "%");
|
||||||
|
}
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
var rows = new java.util.ArrayList<DdsCustomerRow>();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
Timestamp timestamp = resultSet.getTimestamp("signup_date");
|
||||||
|
rows.add(new DdsCustomerRow(
|
||||||
|
resultSet.getLong("customer_id"),
|
||||||
|
resultSet.getString("full_name"),
|
||||||
|
resultSet.getString("email"),
|
||||||
|
timestamp == null ? null : timestamp.toLocalDateTime(),
|
||||||
|
resultSet.getString("region")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return new DdsQueryResult(
|
||||||
|
normalizedUserKey, normalizedSourceKey, userLabel, source.label(), source.objectName(),
|
||||||
|
true, "DDS 조회가 완료되었습니다.",
|
||||||
|
rows.isEmpty() ? "조건에 맞는 행이 없습니다." : rows.size() + "개 행이 DDS 정책을 통과했습니다.",
|
||||||
|
sessionUser, endUser, null, rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
return failure(normalizedUserKey, normalizedSourceKey, userLabel, source,
|
||||||
|
classifyTitle(e), classifyMessage(e), oracleCode(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DdsQueryResult failure(
|
||||||
|
String userKey,
|
||||||
|
String sourceKey,
|
||||||
|
String userLabel,
|
||||||
|
DdsSourceOption source,
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
Integer oracleCode
|
||||||
|
) {
|
||||||
|
return new DdsQueryResult(
|
||||||
|
userKey, sourceKey, userLabel, source.label(), source.objectName(),
|
||||||
|
false, title, message, null, null, oracleCode, List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readSingleValue(Connection connection, String sql, int timeoutSeconds)
|
||||||
|
throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
return resultSet.next() ? resultSet.getString(1) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeUserKey(String value) {
|
||||||
|
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeSourceKey(String value) {
|
||||||
|
var normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!SAFE_SOURCE.matcher(normalized).matches()) {
|
||||||
|
throw new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int timeoutSeconds(Duration duration) {
|
||||||
|
return Math.max(1, (int) Math.ceil(duration.toMillis() / 1000.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String valueOrDefault(String value, String fallback) {
|
||||||
|
return value == null || value.isBlank() ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer oracleCode(SQLException exception) {
|
||||||
|
SQLException current = exception;
|
||||||
|
while (current != null) {
|
||||||
|
if (current.getErrorCode() != 0) {
|
||||||
|
return Math.abs(current.getErrorCode());
|
||||||
|
}
|
||||||
|
current = current.getNextException();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String flattenedMessage(SQLException exception) {
|
||||||
|
var messages = new StringBuilder();
|
||||||
|
SQLException current = exception;
|
||||||
|
while (current != null) {
|
||||||
|
if (messages.length() > 0) {
|
||||||
|
messages.append("; ");
|
||||||
|
}
|
||||||
|
messages.append(Objects.toString(current.getMessage(), ""));
|
||||||
|
current = current.getNextException();
|
||||||
|
}
|
||||||
|
return messages.toString().replaceAll("\\s+", " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String classifyTitle(SQLException exception) {
|
||||||
|
var message = flattenedMessage(exception).toUpperCase(Locale.ROOT);
|
||||||
|
if (message.contains("ORA-00942")) {
|
||||||
|
return "이 DDS 사용자에게 데이터가 허용되지 않았습니다.";
|
||||||
|
}
|
||||||
|
if (message.contains("ORA-01017")) {
|
||||||
|
return "DDS 사용자 인증에 실패했습니다.";
|
||||||
|
}
|
||||||
|
if (message.contains("ORA-12154") || message.contains("ORA-12514")
|
||||||
|
|| message.contains("IO ERROR") || message.contains("CONNECTION")) {
|
||||||
|
return "DDS 데이터베이스에 연결할 수 없습니다.";
|
||||||
|
}
|
||||||
|
return "DDS 조회 중 데이터베이스 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String classifyMessage(SQLException exception) {
|
||||||
|
var message = flattenedMessage(exception);
|
||||||
|
var upper = message.toUpperCase(Locale.ROOT);
|
||||||
|
if (upper.contains("ORA-00942")) {
|
||||||
|
return "해당 END USER에 연결된 DATA ROLE의 DATA GRANT가 없어 보호 객체가 보이지 않습니다. DDS의 default deny 결과입니다.";
|
||||||
|
}
|
||||||
|
if (upper.contains("ORA-01017")) {
|
||||||
|
return "서버에 등록된 DDS END USER 비밀번호와 일치하지 않습니다.";
|
||||||
|
}
|
||||||
|
if (upper.contains("ORA-12154") || upper.contains("ORA-12514")) {
|
||||||
|
return "TNS 별칭 또는 서비스 이름을 확인하세요.";
|
||||||
|
}
|
||||||
|
return "DB 접속 정보와 DDS DATA GRANT 설정을 확인하세요. 상세 코드는 운영 로그에서 확인합니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult;
|
||||||
|
import com.cloudhandson.ddsbackoffice.service.DdsQueryService;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class DdsController {
|
||||||
|
|
||||||
|
private final DdsQueryService service;
|
||||||
|
|
||||||
|
public DdsController(DdsQueryService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping({"/", "/dds"})
|
||||||
|
public String page(Model model) {
|
||||||
|
addOptions(model, "both", "PG", null);
|
||||||
|
return "dds";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/dds/query")
|
||||||
|
public String query(
|
||||||
|
@RequestParam String userKey,
|
||||||
|
@RequestParam String sourceKey,
|
||||||
|
@RequestParam(required = false) String searchText,
|
||||||
|
@RequestParam(defaultValue = "20") int limit,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
DdsQueryResult result;
|
||||||
|
try {
|
||||||
|
result = service.query(userKey, sourceKey, searchText, limit);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
result = new DdsQueryResult(
|
||||||
|
userKey, sourceKey, userKey, sourceKey, "-", false,
|
||||||
|
"입력값을 확인하세요.", e.getMessage(), null, null, null, java.util.List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addOptions(model, userKey, sourceKey, result);
|
||||||
|
return "dds";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addOptions(Model model, String userKey, String sourceKey, DdsQueryResult result) {
|
||||||
|
var users = service.users();
|
||||||
|
model.addAttribute("users", users);
|
||||||
|
model.addAttribute("configuredUserCount", users.stream().filter(user -> user.configured()).count());
|
||||||
|
model.addAttribute("sources", service.sources());
|
||||||
|
model.addAttribute("selectedUser", userKey);
|
||||||
|
model.addAttribute("selectedSource", sourceKey);
|
||||||
|
model.addAttribute("queryResult", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public Map<String, String> health() {
|
||||||
|
return Map.of("status", "UP", "track", "DDS");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class LoginController {
|
||||||
|
|
||||||
|
@GetMapping("/login")
|
||||||
|
public String login() {
|
||||||
|
return "login";
|
||||||
|
}
|
||||||
|
}
|
||||||
50
dds-backoffice/src/main/resources/application.yml
Normal file
50
dds-backoffice/src/main/resources/application.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: dds-permission-backoffice
|
||||||
|
thymeleaf:
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
address: ${DDS_BACKOFFICE_BIND_ADDRESS:0.0.0.0}
|
||||||
|
port: ${DDS_BACKOFFICE_PORT:8083}
|
||||||
|
forward-headers-strategy: ${DDS_BACKOFFICE_FORWARD_HEADERS_STRATEGY:none}
|
||||||
|
servlet:
|
||||||
|
session:
|
||||||
|
cookie:
|
||||||
|
name: DDS_BACKOFFICE_SESSION
|
||||||
|
secure: ${DDS_BACKOFFICE_SESSION_COOKIE_SECURE:false}
|
||||||
|
http-only: true
|
||||||
|
same-site: lax
|
||||||
|
|
||||||
|
backoffice:
|
||||||
|
security:
|
||||||
|
admin-user: ${DDS_BACKOFFICE_ADMIN_USER:${BACKOFFICE_ADMIN_USER:admin}}
|
||||||
|
admin-password: ${DDS_BACKOFFICE_ADMIN_PASSWORD:${BACKOFFICE_ADMIN_PASSWORD:admin}}
|
||||||
|
require-https: ${DDS_BACKOFFICE_REQUIRE_HTTPS:false}
|
||||||
|
|
||||||
|
dds:
|
||||||
|
db-url: ${DDS_BACKOFFICE_DB_URL:${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}}
|
||||||
|
query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s}
|
||||||
|
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}
|
||||||
|
my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY}
|
||||||
|
users:
|
||||||
|
my:
|
||||||
|
label: MY 전용 사용자
|
||||||
|
description: MySQL 원본만 허용하는 DATA ROLE
|
||||||
|
username: ${DDS_BACKOFFICE_MY_USERNAME:ddsuser_my}
|
||||||
|
password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}}
|
||||||
|
pg:
|
||||||
|
label: PG 전용 사용자
|
||||||
|
description: PostgreSQL 원본만 허용하는 DATA ROLE
|
||||||
|
username: ${DDS_BACKOFFICE_PG_USERNAME:ddsuser_pg}
|
||||||
|
password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}}
|
||||||
|
both:
|
||||||
|
label: 통합 사용자
|
||||||
|
description: 두 원본을 모두 허용하는 DATA ROLE
|
||||||
|
username: ${DDS_BACKOFFICE_BOTH_USERNAME:ddsuser_both}
|
||||||
|
password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}}
|
||||||
|
none:
|
||||||
|
label: 차단 사용자
|
||||||
|
description: 접속만 가능하고 DATA GRANT가 없는 사용자
|
||||||
|
username: ${DDS_BACKOFFICE_NONE_USERNAME:ddsuser_none}
|
||||||
|
password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}}
|
||||||
74
dds-backoffice/src/main/resources/static/css/dds.css
Normal file
74
dds-backoffice/src/main/resources/static/css/dds.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
:root {
|
||||||
|
--dds-ink: #17213a;
|
||||||
|
--dds-muted: #68738a;
|
||||||
|
--dds-line: #dfe5ef;
|
||||||
|
--dds-surface: #ffffff;
|
||||||
|
--dds-bg: #f4f7fb;
|
||||||
|
--dds-accent: #3157d5;
|
||||||
|
--dds-accent-dark: #203a9d;
|
||||||
|
--dds-success: #11765a;
|
||||||
|
--dds-warning: #9a6512;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family: Inter, "Noto Sans KR", system-ui, -apple-system, sans-serif; }
|
||||||
|
.dds-nav { background: #101a36; color: #fff; padding: 15px 0; box-shadow: 0 2px 12px rgba(16, 26, 54, .18); }
|
||||||
|
.brand { color: #fff; text-decoration: none; font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.track-badge { border: 1px solid rgba(255,255,255,.28); border-radius: 999px; color: #dbe4ff; font-size: .78rem; padding: 4px 10px; }
|
||||||
|
.hero { padding: 48px 0 26px; max-width: 920px; }
|
||||||
|
.hero h1 { font-size: clamp(2rem, 4vw, 3.2rem); letter-spacing: -.05em; line-height: 1.15; margin: 10px 0 15px; }
|
||||||
|
.hero .lead { color: var(--dds-muted); font-size: 1.1rem; line-height: 1.75; max-width: 820px; }
|
||||||
|
.eyebrow { color: var(--dds-accent); font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.dds-nav .track-badge, .dds-nav .eyebrow { color: #dbe4ff; }
|
||||||
|
.explanation { background: #edf2ff; border: 1px solid #d4defe; border-radius: 12px; color: #34466e; margin-top: 22px; padding: 14px 17px; }
|
||||||
|
.explanation summary { cursor: pointer; font-weight: 700; }
|
||||||
|
.explanation p { line-height: 1.65; margin: 12px 0 0; }
|
||||||
|
.flow-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 5px 0 28px; }
|
||||||
|
.flow-card { align-items: flex-start; background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 14px; display: flex; gap: 14px; min-height: 142px; padding: 20px; }
|
||||||
|
.flow-number { align-items: center; background: #e8edff; border-radius: 50%; color: var(--dds-accent); display: inline-flex; flex: 0 0 32px; font-weight: 800; height: 32px; justify-content: center; }
|
||||||
|
.result-number { background: #e2f4ed; color: var(--dds-success); }
|
||||||
|
.flow-card h2 { font-size: 1rem; margin: 5px 0 7px; }
|
||||||
|
.flow-card p { color: var(--dds-muted); font-size: .9rem; line-height: 1.55; margin: 0; }
|
||||||
|
.panel { background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 16px; margin: 18px 0; padding: 25px; box-shadow: 0 5px 18px rgba(31, 51, 93, .04); }
|
||||||
|
.panel-heading { align-items: flex-start; display: flex; justify-content: space-between; gap: 16px; }
|
||||||
|
.panel-heading h2 { font-size: 1.35rem; letter-spacing: -.03em; margin: 6px 0 6px; }
|
||||||
|
.panel-heading p { color: var(--dds-muted); margin: 0; }
|
||||||
|
.query-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 22px; }
|
||||||
|
.query-grid label { color: #34415c; font-size: .9rem; font-weight: 700; }
|
||||||
|
.query-grid .form-control, .query-grid .form-select { margin-top: 7px; }
|
||||||
|
.field-help { color: var(--dds-muted); display: block; font-size: .78rem; font-weight: 400; margin-top: 6px; }
|
||||||
|
.query-submit { align-items: end; display: flex; }
|
||||||
|
.query-submit .btn { min-height: 45px; width: 100%; }
|
||||||
|
.matrix-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 20px; }
|
||||||
|
.matrix-card { background: #f5f7ff; border: 1px solid #dce3ff; border-radius: 12px; display: flex; flex-direction: column; gap: 6px; min-height: 118px; padding: 16px; }
|
||||||
|
.matrix-card strong { color: var(--dds-accent-dark); }
|
||||||
|
.matrix-card span { font-size: .9rem; font-weight: 700; }
|
||||||
|
.matrix-card small { color: var(--dds-muted); line-height: 1.4; }
|
||||||
|
.matrix-card.muted { background: #f8f8f8; border-color: #e5e5e5; }
|
||||||
|
.matrix-card.muted strong { color: #5b6270; }
|
||||||
|
.result-heading { align-items: center; }
|
||||||
|
.result-heading p { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; }
|
||||||
|
.result-status { border-radius: 999px; color: var(--dds-warning); background: #fff4dc; font-size: .8rem; font-weight: 800; padding: 7px 12px; }
|
||||||
|
.result-status.success { color: var(--dds-success); background: #e3f6ed; }
|
||||||
|
.result-status.blocked { color: #a34242; background: #fde8e8; }
|
||||||
|
.technical-code { color: var(--dds-muted); display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; margin-top: 5px; }
|
||||||
|
.context-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 18px 0; }
|
||||||
|
.context-grid > div { background: #f7f9fc; border: 1px solid var(--dds-line); border-radius: 10px; padding: 12px 14px; }
|
||||||
|
.context-grid span { color: var(--dds-muted); display: block; font-size: .72rem; font-weight: 700; letter-spacing: .05em; }
|
||||||
|
.context-grid strong { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-top: 5px; }
|
||||||
|
.table { margin-top: 8px; }
|
||||||
|
.table thead th { color: var(--dds-muted); font-size: .75rem; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.empty-result { background: #f7f9fc; border-radius: 10px; color: var(--dds-muted); padding: 18px; text-align: center; }
|
||||||
|
.login-page { align-items: center; display: flex; justify-content: center; min-height: 100vh; padding: 22px; }
|
||||||
|
.login-card { background: #fff; border: 1px solid var(--dds-line); border-radius: 16px; box-shadow: 0 15px 45px rgba(31, 51, 93, .12); max-width: 420px; padding: 35px; width: 100%; }
|
||||||
|
.login-card h1 { font-size: 1.65rem; margin: 8px 0; }
|
||||||
|
.login-card p { color: var(--dds-muted); margin-bottom: 26px; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.query-grid, .context-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.flow-grid, .matrix-grid { grid-template-columns: 1fr; }
|
||||||
|
.panel { padding: 19px; }
|
||||||
|
.hero { padding-top: 30px; }
|
||||||
|
}
|
||||||
151
dds-backoffice/src/main/resources/templates/dds.html
Normal file
151
dds-backoffice/src/main/resources/templates/dds.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Deep Data Security 접근 관리</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/css/dds.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="dds-nav">
|
||||||
|
<div class="container d-flex align-items-center gap-3">
|
||||||
|
<a class="brand" href="/">DDS Permission Console</a>
|
||||||
|
<span class="track-badge">별도 인스턴스 · :8083</span>
|
||||||
|
<form method="post" action="/logout" class="ms-auto">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<button class="btn btn-sm btn-outline-light" type="submit">로그아웃</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container py-4">
|
||||||
|
<header class="hero">
|
||||||
|
<span class="eyebrow">DEEP DATA SECURITY · DIRECT END USER</span>
|
||||||
|
<h1>선언형 데이터 권한을 실제 조회로 확인합니다.</h1>
|
||||||
|
<p class="lead">VPD 트랙과 분리된 DDS 전용 인스턴스입니다. 선택한 END USER로 직접 접속해 Oracle의 DATA ROLE과 DATA GRANT가 적용된 결과를 보여줍니다.</p>
|
||||||
|
<details class="explanation">
|
||||||
|
<summary>이 화면의 보안 경계</summary>
|
||||||
|
<p>이 인스턴스는 VPD 권한 테이블을 읽어 조건을 계산하지 않습니다. <code>END USER → DATA ROLE → DATA GRANT → 보호 VIEW</code>가 전부 Oracle DDS에 선언되어 있고, 조회 결과는 그 선언의 적용 결과입니다.</p>
|
||||||
|
<p class="mb-0">Bearer 키를 ORDS Handler에서 사용자 이름으로 바꾸는 것만으로는 DDS 보안 컨텍스트가 만들어지지 않습니다. 그래서 이 비교 트랙은 직접 END USER logon을 검증합니다.</p>
|
||||||
|
</details>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="flow-grid" aria-label="DDS 접근 흐름">
|
||||||
|
<article class="flow-card">
|
||||||
|
<span class="flow-number">1</span>
|
||||||
|
<div><span class="eyebrow">IDENTITY</span><h2>END USER</h2><p>실제 DDS 보안 사용자로 DB에 접속합니다.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="flow-card">
|
||||||
|
<span class="flow-number">2</span>
|
||||||
|
<div><span class="eyebrow">POLICY</span><h2>DATA ROLE / GRANT</h2><p>원본별 허용 범위가 선언형으로 연결됩니다.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="flow-card">
|
||||||
|
<span class="flow-number">3</span>
|
||||||
|
<div><span class="flow-number result-number">3</span><span class="eyebrow">EVIDENCE</span><h2>조회 결과</h2><p>허용된 VIEW와 행만 반환됩니다.</p></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" th:if="${configuredUserCount == 0}">
|
||||||
|
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 <code>DDSUSER_*_PASSWORD</code> 또는 <code>DDS_BACKOFFICE_*_PASSWORD</code> 환경 변수를 설정해야 조회할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">1 · SECURITY SUBJECT</span>
|
||||||
|
<h2>조회 주체와 데이터 원본 선택</h2>
|
||||||
|
<p>선택한 DDS END USER의 권한으로 직접 연결합니다. 비밀번호는 화면에 표시하거나 저장하지 않습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/dds/query" class="query-grid">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>
|
||||||
|
DDS END USER
|
||||||
|
<select class="form-select" name="userKey" required>
|
||||||
|
<option th:each="user : ${users}" th:value="${user.key()}" th:selected="${user.key() == selectedUser}"
|
||||||
|
th:text="${user.label() + ' · ' + user.username()}"></option>
|
||||||
|
</select>
|
||||||
|
<span class="field-help" th:each="user : ${users}" th:if="${user.key() == selectedUser}" th:text="${user.description()}"></span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
보호 데이터 원본
|
||||||
|
<select class="form-select" name="sourceKey" required>
|
||||||
|
<option th:each="source : ${sources}" th:value="${source.key()}" th:selected="${source.key() == selectedSource}"
|
||||||
|
th:text="${source.label()}"></option>
|
||||||
|
</select>
|
||||||
|
<span class="field-help" th:each="source : ${sources}" th:if="${source.key() == selectedSource}" th:text="${source.objectName()}"></span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
이름 검색 (선택)
|
||||||
|
<input class="form-control" name="searchText" placeholder="예: Alice">
|
||||||
|
<span class="field-help">검색 조건도 보호 VIEW 안에서만 평가됩니다.</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
최대 행 수
|
||||||
|
<input class="form-control" name="limit" type="number" min="1" max="100" value="20">
|
||||||
|
</label>
|
||||||
|
<div class="query-submit"><button class="btn btn-primary" type="submit">DDS 권한으로 조회</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">2 · EXPECTED MATRIX</span>
|
||||||
|
<h2>원본별 권한 매트릭스</h2>
|
||||||
|
<p>VPD와 같은 4가지 업무 사례를 DDS의 선언형 권한으로 재현합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="matrix-grid">
|
||||||
|
<div class="matrix-card"><strong>ddsuser_my</strong><span>MY 원본 허용</span><small>PG 객체는 ORA-00942</small></div>
|
||||||
|
<div class="matrix-card"><strong>ddsuser_pg</strong><span>PG 원본 허용</span><small>MY 객체는 ORA-00942</small></div>
|
||||||
|
<div class="matrix-card"><strong>ddsuser_both</strong><span>PG + MY 허용</span><small>두 DATA GRANT 모두 적용</small></div>
|
||||||
|
<div class="matrix-card muted"><strong>ddsuser_none</strong><span>접속만 허용</span><small>데이터 권한 없음 · default deny</small></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" th:if="${queryResult != null}">
|
||||||
|
<div class="panel-heading result-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">3 · EVIDENCE</span>
|
||||||
|
<h2 th:text="${queryResult.userLabel() + ' · ' + queryResult.sourceLabel()}">조회 결과</h2>
|
||||||
|
<p th:text="${queryResult.objectName()}">ADMIN.V_DDS_CUSTOMERS_PG</p>
|
||||||
|
</div>
|
||||||
|
<span class="result-status" th:classappend="${queryResult.success()} ? ' success' : ' blocked'"
|
||||||
|
th:text="${queryResult.success()} ? '정책 적용됨' : '접근 차단'">상태</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert" th:classappend="${queryResult.success()} ? ' alert-success' : ' alert-warning'">
|
||||||
|
<strong th:text="${queryResult.title()}">결과</strong>
|
||||||
|
<span th:text="${queryResult.message()}">메시지</span>
|
||||||
|
<span th:if="${queryResult.oracleCode() != null}" class="technical-code" th:text="${'Oracle code: ' + queryResult.oracleCode()}"></span>
|
||||||
|
</div>
|
||||||
|
<div class="context-grid" th:if="${queryResult.success()}">
|
||||||
|
<div><span>SESSION_USER</span><strong th:text="${queryResult.sessionUser() ?: '-'}">-</strong></div>
|
||||||
|
<div><span>ORA_END_USER_CONTEXT</span><strong th:text="${queryResult.endUser() ?: '-'}">-</strong></div>
|
||||||
|
<div><span>반환 행</span><strong th:text="${#lists.size(queryResult.rows())}">0</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" th:if="${queryResult.success() and queryResult.hasRows()}">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead><tr><th>ID</th><th>이름</th><th>이메일</th><th>가입일</th><th>지역</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="row : ${queryResult.rows()}">
|
||||||
|
<td th:text="${row.customerId()}">1</td>
|
||||||
|
<td th:text="${row.fullName()}">Alice</td>
|
||||||
|
<td th:text="${row.email() ?: 'NULL'}">alice@example.com</td>
|
||||||
|
<td th:text="${row.signupDate() ?: '-'}">2026-01-01</td>
|
||||||
|
<td th:text="${row.region()}">APAC</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="empty-result" th:if="${queryResult.success() and !queryResult.hasRows()}">조회 조건을 통과한 행이 없습니다.</div>
|
||||||
|
<details class="explanation">
|
||||||
|
<summary>판정 근거와 운영 메모</summary>
|
||||||
|
<p>성공한 경우 연결 시점의 <code>SESSION_USER</code>와 <code>ORA_END_USER_CONTEXT</code>를 함께 확인할 수 있습니다. 차단된 경우 DDS가 보호 객체 자체를 숨겨 <code>ORA-00942</code>를 반환하는 것이 정상입니다.</p>
|
||||||
|
<p class="mb-0">현재 앱은 결과만 읽습니다. 권한 변경은 <code>CREATE DATA ROLE</code>, <code>GRANT DATA ROLE</code>, <code>CREATE DATA GRANT</code> 승인 절차를 거쳐 SQL로 관리합니다.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
dds-backoffice/src/main/resources/templates/login.html
Normal file
27
dds-backoffice/src/main/resources/templates/login.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DDS Permission Console 로그인</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/css/dds.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<main class="login-card">
|
||||||
|
<span class="eyebrow">DEEP DATA SECURITY</span>
|
||||||
|
<h1>DDS Permission Console</h1>
|
||||||
|
<p>VPD와 분리된 DDS 전용 인스턴스입니다.</p>
|
||||||
|
<div class="alert alert-danger" th:if="${param.error}">관리자 인증에 실패했습니다.</div>
|
||||||
|
<div class="alert alert-success" th:if="${param.logout}">로그아웃되었습니다.</div>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label class="form-label" for="username">관리자 ID</label>
|
||||||
|
<input class="form-control mb-3" id="username" name="username" autocomplete="username" required autofocus>
|
||||||
|
<label class="form-label" for="password">비밀번호</label>
|
||||||
|
<input class="form-control mb-4" id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">로그인</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class DdsPropertiesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesObjectsAndUserKeys() {
|
||||||
|
var properties = new DdsProperties(
|
||||||
|
"jdbc:test",
|
||||||
|
Duration.ofSeconds(5),
|
||||||
|
"admin.v_dds_customers_pg",
|
||||||
|
"admin.v_dds_customers_my",
|
||||||
|
Map.of("MY", new DdsProperties.User("MY", "source", "ddsuser_my", "secret"))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("ADMIN.V_DDS_CUSTOMERS_PG", properties.objectFor("pg"));
|
||||||
|
assertEquals("ADMIN.V_DDS_CUSTOMERS_MY", properties.objectFor("MY"));
|
||||||
|
assertTrue(properties.users().containsKey("my"));
|
||||||
|
assertTrue(properties.users().get("my").configured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsafeObjectNames() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new DdsProperties(
|
||||||
|
"jdbc:test", Duration.ofSeconds(5), "ADMIN.VIEW;DROP", "ADMIN.VIEW", Map.of()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class DdsQueryServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsMissingDatabaseConfigurationWithoutOpeningAConnection() {
|
||||||
|
var properties = new DdsProperties(
|
||||||
|
"", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY",
|
||||||
|
Map.of("my", new DdsProperties.User("MY", "source", "ddsuser_my", "secret"))
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new DdsQueryService(properties).query("my", "PG", "", 20);
|
||||||
|
|
||||||
|
assertFalse(result.success());
|
||||||
|
assertTrue(result.title().contains("DB 접속"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownSourceBeforeOpeningAConnection() {
|
||||||
|
var properties = new DdsProperties(
|
||||||
|
"jdbc:test", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY",
|
||||||
|
Map.of("my", new DdsProperties.User("MY", "source", "ddsuser_my", "secret"))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new DdsQueryService(properties).query("my", "RDS", "", 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
105
scripts/deploy-dds-backoffice-local.sh
Executable file
105
scripts/deploy-dds-backoffice-local.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy the dedicated DDS backoffice beside the existing VPD instance.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
APP_DIR="${DDS_BACKOFFICE_APP_DIR:-/home/opc/apps/dds-backoffice}"
|
||||||
|
PORT="${DDS_BACKOFFICE_PORT:-8083}"
|
||||||
|
|
||||||
|
cd "$ROOT/dds-backoffice"
|
||||||
|
mvn -DskipTests package
|
||||||
|
JAR="$ROOT/dds-backoffice/target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar"
|
||||||
|
[[ -f "$JAR" ]] || { echo "missing jar: $JAR" >&2; exit 1; }
|
||||||
|
|
||||||
|
install -d -o opc -g opc "$APP_DIR"
|
||||||
|
|
||||||
|
cat > "$APP_DIR/start.sh" <<'START'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
set -a
|
||||||
|
if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then
|
||||||
|
. /home/opc/apps/vpd-backoffice/.env
|
||||||
|
fi
|
||||||
|
if [[ -f "$APP_DIR/.env" ]]; then
|
||||||
|
. "$APP_DIR/.env"
|
||||||
|
fi
|
||||||
|
set +a
|
||||||
|
|
||||||
|
export DDS_BACKOFFICE_PORT="${DDS_BACKOFFICE_PORT:-8083}"
|
||||||
|
export DDS_BACKOFFICE_BIND_ADDRESS="${DDS_BACKOFFICE_BIND_ADDRESS:-127.0.0.1}"
|
||||||
|
export DDS_BACKOFFICE_DB_URL="${DDS_BACKOFFICE_DB_URL:-${BACKOFFICE_DB_URL:-}}"
|
||||||
|
export DDS_BACKOFFICE_PG_PASSWORD="${DDS_BACKOFFICE_PG_PASSWORD:-${DDSUSER_PG_PASSWORD:-}}"
|
||||||
|
export DDS_BACKOFFICE_MY_PASSWORD="${DDS_BACKOFFICE_MY_PASSWORD:-${DDSUSER_MY_PASSWORD:-}}"
|
||||||
|
export DDS_BACKOFFICE_BOTH_PASSWORD="${DDS_BACKOFFICE_BOTH_PASSWORD:-${DDSUSER_BOTH_PASSWORD:-}}"
|
||||||
|
export DDS_BACKOFFICE_NONE_PASSWORD="${DDS_BACKOFFICE_NONE_PASSWORD:-${DDSUSER_NONE_PASSWORD:-}}"
|
||||||
|
|
||||||
|
PID_FILE="$APP_DIR/app.pid"
|
||||||
|
LOG_FILE="$APP_DIR/app.log"
|
||||||
|
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1; then
|
||||||
|
echo "already running pid=$(cat "$PID_FILE")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
setsid nohup java -jar "$APP_DIR/app.jar" > "$LOG_FILE" 2>&1 < /dev/null &
|
||||||
|
echo $! > "$PID_FILE"
|
||||||
|
echo "started pid=$(cat "$PID_FILE") log=$LOG_FILE port=$DDS_BACKOFFICE_PORT"
|
||||||
|
START
|
||||||
|
|
||||||
|
cat > "$APP_DIR/stop.sh" <<'STOP'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PID_FILE="$APP_DIR/app.pid"
|
||||||
|
if [[ ! -f "$PID_FILE" ]]; then
|
||||||
|
echo "not running: pid file not found"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
PID="$(cat "$PID_FILE")"
|
||||||
|
if kill -0 "$PID" >/dev/null 2>&1; then
|
||||||
|
kill "$PID"
|
||||||
|
for _ in {1..20}; do
|
||||||
|
kill -0 "$PID" >/dev/null 2>&1 || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
echo "stopped"
|
||||||
|
STOP
|
||||||
|
|
||||||
|
cat > "$APP_DIR/status.sh" <<'STATUS'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PID_FILE="$APP_DIR/app.pid"
|
||||||
|
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1; then
|
||||||
|
echo "running pid=$(cat "$PID_FILE")"
|
||||||
|
else
|
||||||
|
echo "stopped"
|
||||||
|
fi
|
||||||
|
STATUS
|
||||||
|
|
||||||
|
chmod +x "$APP_DIR/start.sh" "$APP_DIR/stop.sh" "$APP_DIR/status.sh"
|
||||||
|
if [[ ! -f "$APP_DIR/.env" ]]; then
|
||||||
|
cat > "$APP_DIR/.env" <<ENV
|
||||||
|
# DDS instance overrides. Secrets stay in the existing VPD env file or in this file.
|
||||||
|
export DDS_BACKOFFICE_PORT="$PORT"
|
||||||
|
export DDS_BACKOFFICE_BIND_ADDRESS="127.0.0.1"
|
||||||
|
ENV
|
||||||
|
chmod 600 "$APP_DIR/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$APP_DIR/stop.sh" || true
|
||||||
|
install -o opc -g opc -m 0644 "$JAR" "$APP_DIR/app.jar"
|
||||||
|
"$APP_DIR/start.sh"
|
||||||
|
|
||||||
|
for _ in {1..30}; do
|
||||||
|
if "$APP_DIR/status.sh" | grep -q '^running ' && curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/health"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"$APP_DIR/status.sh"
|
||||||
|
curl -sS -o /tmp/dds-backoffice-health.json -w 'health=%{http_code}\n' "http://127.0.0.1:${PORT}/health"
|
||||||
|
echo "DDS backoffice deployed at ${APP_DIR} (loopback port ${PORT})"
|
||||||
Reference in New Issue
Block a user