feat: add dedicated DDS permission backoffice instance
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user