feat: add dedicated DDS permission backoffice instance

This commit is contained in:
devmrko
2026-06-29 21:19:48 +09:00
parent 6cf75dc42b
commit c4968a0313
22 changed files with 1128 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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) {
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
) {
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
public record DdsSourceOption(
String key,
String label,
String objectName,
String description
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.ddsbackoffice.domain;
public record DdsUserOption(
String key,
String label,
String username,
String description,
boolean configured
) {
}

View File

@@ -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 설정을 확인하세요. 상세 코드는 운영 로그에서 확인합니다.";
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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";
}
}